Skip to main content
doc0blade47/dubdocumentation home
Sign in

Analytics Dashboard

Last updated Oct 12, 2026
Relevant source files

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

Overview

Dub provides a comprehensive analytics dashboard system that tracks and aggregates link clicks, lead submissions, and sale conversions across workspaces, partner programs, and shared public views. The platform combines server-side query routing, Redis caching, and plan-aware validations with rich client-side visualizations—including timeseries charts, conversion funnels, and dimension breakdowns for geographic, device, and referrer data. Users can also configure filters, examine program-specific application pipelines, and export filtered datasets or raw events as CSV and ZIP archives.

Sources: apps/web/app/api/analytics/dashboard/route.ts:1-202, apps/web/app/api/analytics/route.ts:1-137, apps/web/app/api/analytics/export/route.ts:1-132, apps/web/app/ee/api/events/export/route.ts:37-181, apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:13-120, apps/web/ui/analytics/location-section.tsx:19-175, apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:31-122, apps/web/ui/analytics/index.tsx:23-55, apps/web/ui/analytics/device-section.tsx:14-150, apps/web/ui/analytics/chart-section.tsx:26-147, apps/web/ui/analytics/toggle.tsx:40-307, apps/web/ui/analytics/referrers-utms.tsx:42-237

Analytics API query routing

Parameter parsing and path normalization

Server-side analytics endpoints process incoming requests by parsing search parameters, validating workspace permissions and plan limits, and normalizing filter arguments before querying telemetry backends. The core analytics API (/api/analytics) is wrapped with workspace authentication requiring the analytics.read permission and checks click usage limits via throwIfClicksUsageExceeded.

export const GET = withWorkspace(
  async ({ params, searchParams, workspace, session }) => {
    throwIfClicksUsageExceeded(workspace);

Sources: apps/web/app/api/analytics/route.ts:20-22

Query parameters are parsed using parseAnalyticsQuery(searchParams). Path parameters are evaluated against analyticsPathParamsSchema to extract event types and grouping endpoints, supporting backward compatibility where /analytics/[endpoint] patterns map directly to grouping configurations.

let { eventType: oldEvent, endpoint: oldType } =
  analyticsPathParamsSchema.parse(params);
 
// for backwards compatibility (we used to support /analytics/[endpoint] as well)
if (!oldType && oldEvent && VALID_ANALYTICS_ENDPOINTS.includes(oldEvent)) {
  oldType = oldEvent;
  oldEvent = undefined;
}
 
const parsedParams = parseAnalyticsQuery(searchParams);

Sources: apps/web/app/api/analytics/route.ts:24-33

Note

Backward compatibility logic checks if the endpoint parameter is omitted while an event matches VALID_ANALYTICS_ENDPOINTS, promoting it to the grouping parameter.

Warning

Requests referencing deprecated endpoints (/api/analytics/clicks, /api/analytics/count) set isDeprecatedClicksEndpoint to true so the underlying query builder handles legacy aggregation structures correctly.

Sources: apps/web/app/api/analytics/route.ts:28-31, apps/web/app/api/analytics/route.ts:116-118

When requests specify domain and key or external identifiers without an explicit linkId, the router resolves the link via getLinkOrThrow. The filter parameters are then normalized by replacing the domain/key combination with an explicit SQL operator structure on linkId and clearing the standalone domain filter.

if (!linkId && (externalId || (domain && key))) {
  const link = await getLinkOrThrow({
    workspaceId: workspace.id,
    linkId,
    externalId,
    domain: getFirstFilterValue(domain),
    key,
  });
 
  parsedParams.linkId = {
    operator: "IS",
    sqlOperator: "IN",
    values: [link.id],
  };
 
  // since we're filtering for a specific link, exclude domain from filters
  parsedParams.domain = undefined;

Sources: apps/web/app/api/analytics/route.ts:71-88

Workspace folders associated with links or direct folder filters undergo access verification via verifyFolderAccess requiring folders.read permissions. Additionally, program identifiers are verified against the workspace default program, throwing a DubApiError with a forbidden code if a mismatch occurs.

if (programId) {
  const workspaceProgramId = getDefaultProgramIdOrThrow(workspace);
  if (programId !== workspaceProgramId) {
    throw new DubApiError({
      code: "forbidden",
      message: `Program ${programId} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,
    });
  }
  if (groupBy === "timeseries") {

Sources: apps/web/app/api/analytics/route.ts:53-61

Dashboard visualization layout and controls

Client-side analytics container and conditional stats grid

The client-side analytics view is initialized through the Analytics component within workspace routes, dashboard previews, and partner portal pages. It wraps child layouts in an AnalyticsProvider context, rendering a sticky top navigation bar via AnalyticsToggle alongside a responsive grid that houses the main chart section and statistical breakdown cards.

export default function Analytics({
  adminPage,
  dashboardProps,
}: {
  adminPage?: boolean;
  dashboardProps?: AnalyticsDashboardProps;
}) {
  return (
    <AnalyticsProvider {...{ adminPage, dashboardProps }}>
      <AnalyticsContext.Consumer>
        {({ dashboardProps }) => {
          return (
            <div
              className={cn("pb-10", dashboardProps && "bg-neutral-50 pt-10")}
            >
              <AnalyticsToggle />
              <div
                className={cn(
                  "mx-auto grid max-w-screen-xl gap-5 px-3 lg:px-10",
                  // TODO: [PageContent] Remove once all pages are migrated to the new PageContent
                  !dashboardProps && !adminPage && "lg:px-6",
                )}
              >
                <ChartSection />
                <StatsGrid />
              </div>
            </div>
          );
        }}
      </AnalyticsContext.Consumer>
    </AnalyticsProvider>
  );
}

Sources: apps/web/ui/analytics/index.tsx:23-55

The StatsGrid component conditionally hides conversion tracking metrics based on the selected tab and workspace plan tier. Specifically, when the selected tab is set to leads, sales, or the view mode is set to funnel under a free or pro plan, the stats grid returns null to restrict advanced conversion insights to higher tiers.

function StatsGrid() {
  const { dashboardProps, selectedTab, view } = useContext(AnalyticsContext);
  const { plan } = useWorkspace();
 
  const hide =
    (selectedTab === "leads" || selectedTab === "sales" || view === "funnel") &&
    (plan === "free" || plan === "pro");
 
  return hide ? null : (
    <div className="grid grid-cols-1 gap-5 md:grid-cols-2">
      {!dashboardProps?.key && <TopLinks />}
      <ReferrersUTMs />
      <LocationSection />
      <DeviceSection />
    </div>
  );
}

Sources: apps/web/ui/analytics/index.tsx:57-72

Warning

Attempting to view leads, sales, or funnel views on free and pro workspaces completely unmounts the underlying dimension cards (TopLinks, ReferrersUTMs, LocationSection, and DeviceSection), preventing unauthorized metric exposure.

Sources: apps/web/ui/analytics/index.tsx:61-64

Date filtering, interval selection, and plan validation

The AnalyticsToggle component manages date range parameters, filtering state, and interval selections. It integrates a DateRangePicker utilizing interval presets defined by INTERVAL_DISPLAYS, allowing users to switch between standard pre-configured time ranges or custom date bounds.

const dateRangePicker = (
  <DateRangePicker
    className="w-full md:w-fit"
    align="start"
    value={
      start && end
        ? {
            from: start,
            to: end,
          }
        : undefined
    }
    presetId={
      start && end ? undefined : interval ?? DUB_LINKS_ANALYTICS_INTERVAL
    }
    onChange={(range, preset) => {
      if (preset) {
        queryParams({
          del: ["start", "end"],
          set: {
            interval: preset.id,
          },
        });
 
        return;
      }
 
      // Regular range
      if (!range || !range.from || !range.to) return;
 
      queryParams({
        del: "interval",
        set: {
          start: range.from.toISOString(),
          end: range.to.toISOString(),
        },
      });
    }}
    presets={INTERVAL_DISPLAYS.map(({ display, value, shortcut }) => {
      const requiresUpgrade =
        partnerPage ||
        DUB_DEMO_LINKS.find((l) => l.domain === domain && l.key === key)
          ? false
          : !validDateRangeForPlan({
              plan: plan || dashboardProps?.workspacePlan,
              dataAvailableFrom: createdAt,
              interval: value,
              start,
              end,
            }).valid;
 
      const { startDate, endDate } = getStartEndDates({
        interval: value,
        dataAvailableFrom: createdAt,
      });
 
      return {
        id: value,
        label: display,
        dateRange: {
          from: startDate,
          to: endDate,
        },
        requiresUpgrade,
        tooltipContent: requiresUpgrade ? (
          <UpgradeTooltip rangeLabel={display} plan={plan} />
        ) : undefined,
        shortcut,
      };
    })}
  />

Sources: apps/web/ui/analytics/toggle.tsx:94-164

When a user selects an interval preset, AnalyticsToggle updates the URL search parameters by deleting explicit start and end bounds while setting the interval query parameter to the preset ID. Conversely, selecting a custom date range removes the interval parameter and encodes ISO strings for start and end.

onChange={(range, preset) => {
  if (preset) {
    queryParams({
      del: ["start", "end"],
      set: {
        interval: preset.id,
      },
    });
 
    return;
  }
 
  // Regular range
  if (!range || !range.from || !range.to) return;
 
  queryParams({
    del: "interval",
    set: {
      start: range.from.toISOString(),
      end: range.to.toISOString(),
    },
  });
}}

Sources: apps/web/ui/analytics/toggle.tsx:109-131

Tip

Preset validation checks plan capabilities using validDateRangeForPlan against workspace creation dates. If an interval exceeds the plan limit, an UpgradeTooltip is attached to prompt users for a plan upgrade.

Sources: apps/web/ui/analytics/toggle.tsx:132-163

Timeseries and funnel metric charts

Chart section container and tab management

The ChartSection component coordinates the visualization rendering inside the dashboard. It inspects workspace tier parameters alongside showConversions flags to build the available event tabs: clicks, leads, and sales.

export function ChartSection() {
  const {
    totalEvents,
    requiresUpgrade,
    showConversions,
    selectedTab,
    saleUnit,
    view,
  } = useContext(AnalyticsContext);
  const { plan } = useWorkspace();
  const { queryParams } = useRouterStuff();
 
  const tabs = useMemo(
    () =>
      [
        {
          id: "clicks",
          label: "Clicks",
          colorClassName: "text-blue-500/50",
          conversions: false,
        },
        ...(showConversions
          ? [
              {
                id: "leads",
                label: "Leads",
                colorClassName: "text-violet-600/50",
                conversions: true,
              },
              {
                id: "sales",
                label: "Sales",
                colorClassName: "text-teal-400/50",
                conversions: true,
              },
            ]
          : []),
      ] as Tab[],
    [showConversions],
  );

Sources: apps/web/ui/analytics/chart-section.tsx:26-65

Each tab holds an identifier, display label, color styling class, and a boolean indicating whether it represents a conversion metric.

Tab IDLabelColor Class NameConversions
clicksClickstext-blue-500/50false
leadsLeadstext-violet-600/50true
salesSalestext-teal-400/50true

Sources: apps/web/ui/analytics/chart-section.tsx:41-61

Warning

When showPaywall evaluates to true—triggered when accessing conversion tabs or funnel views on free or pro workspace plans—the chart container applies pointer-events disabling alongside linear gradient masking and renders the ConversionTrackingPaywall overlay.

Sources: apps/web/ui/analytics/chart-section.tsx:69-72, apps/web/ui/analytics/chart-section.tsx:99-105, apps/web/ui/analytics/chart-section.tsx:143-143

Program analytics chart data and view modes

Program-specific analytics charts fetch timeseries aggregation datasets from /api/analytics parameterized by groupBy=timeseries and the currently selected event tab. The returned records map start dates and metric counts or sale amounts into chart values.

const { data, error } = useSWR<
  {
    start: Date;
    clicks: number;
    leads: number;
    sales: number;
    saleAmount: number;
  }[]
>(
  `/api/analytics?${editQueryString(queryString ?? "", {
    event: selectedTab,
    groupBy: "timeseries",
  })}`,
  fetcher,
);
 
const chartData = useMemo(
  () =>
    data?.map((d) => ({
      date: new Date(d.start),
      values: {
        amount:
          selectedTab === "sales" && saleUnit === "saleAmount"
            ? d.saleAmount
            : d[selectedTab],
      },
    })),
  [data, selectedTab, saleUnit],
);

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:19-47

The view switcher toggles between timeseries and funnel visualization modes. When loading data, a loading spinner is shown, while fetch errors present a fallback message.

<div className="relative h-72 md:h-96">
  {dataLoading ? (
    <div className="flex size-full items-center justify-center">
      <LoadingSpinner />
    </div>
  ) : error ? (
    <div className="flex size-full items-center justify-center text-sm text-neutral-500">
      Failed to load data
    </div>
  ) : (
    <>
      {view === "timeseries" ? (
        <div className="relative size-full p-6 pt-10">
          <AnalyticsTimeseriesChart data={chartData} />
        </div>
      ) : (
        <AnalyticsFunnelChart />
      )}

Sources: apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:74-91

Geographic, device, and referrer breakdown

Location metrics and country/city bar lists

The LocationSection component manages geographic distributions by rendering an AnalyticsCard with four tabs: countries, cities, regions, and continents. It retrieves data using useAnalyticsFilterOption(tab) alongside an unfiltered dataset (omitGroupByFilterKey: true) to render proportion values inside a BarList.

export function LocationSection() {
  const { queryParams, searchParams } = useRouterStuff();
 
  const { selectedTab, saleUnit } = useContext(AnalyticsContext);
  const dataKey = selectedTab === "sales" ? saleUnit : "count";
 
  const [tab, setTab] = useState<
    "countries" | "cities" | "regions" | "continents"
  >("countries");
 
  const { data } = useAnalyticsFilterOption(tab);
  const { data: allData } = useAnalyticsFilterOption(tab, {
    omitGroupByFilterKey: true,
  });

Sources: apps/web/ui/analytics/location-section.tsx:19-32

Each item formats its display title and icon based on the active tab context. Continent tabs resolve names from CONTINENTS, countries map through COUNTRIES with CountryFlag elements, and regions or cities construct composite location strings with regional fallbacks.

Tab IDIcon ComponentTitle Resolution SourceEndpoint Mapping
countriesCountryFlagCOUNTRIES[d.country]country
citiesCountryFlag`$d.city, $REGIONS[d.region]
regionsCountryFlag`REGIONS[d.region]
continentsContinentIconCONTINENTS[d.continent]continent

Sources: apps/web/ui/analytics/location-section.tsx:74-79, apps/web/ui/analytics/location-section.tsx:94-117

Note

When location filter values are toggled, onApplyFilterValues updates URL query parameters using the singular endpoint name from SINGULAR_ANALYTICS_ENDPOINTS[tab]. Empty selections delete the parameter, whereas multiple selections join values with commas.

Sources: apps/web/ui/analytics/location-section.tsx:47-59

Device, browser, and OS distributions

The DeviceSection component displays technical breakdown statistics across four device categories: devices, browsers, os, and triggers. It utilizes DeviceIcon and TRIGGER_DISPLAY maps to render respective icons and labels for each metric entry.

export function DeviceSection() {
  const { queryParams, searchParams } = useRouterStuff();
 
  const { selectedTab, saleUnit } = useContext(AnalyticsContext);
  const dataKey = selectedTab === "sales" ? saleUnit : "count";
 
  const [tab, setTab] = useState<DeviceTabs>("devices");
  const { data } = useAnalyticsFilterOption(tab);
  const { data: allData } = useAnalyticsFilterOption(tab, {
    omitGroupByFilterKey: true,
  });
  const singularTabName = SINGULAR_ANALYTICS_ENDPOINTS[tab];

Sources: apps/web/ui/analytics/device-section.tsx:14-25

The card styles its elements using green color variables (bg-green-100, hover:bg-green-50, bg-green-600) to visually distinguish device telemetry from other analytics sections.

Tab IDLabelIcon ComponentSingular Endpoint
devicesDevicesMobilePhonedevice
browsersBrowsersWindowbrowser
osOSCubeos
triggersTriggersCursorRaystrigger

Sources: apps/web/ui/analytics/device-section.tsx:66-71, apps/web/lib/analytics/constants.ts

Referrers and UTM parameter breakdowns

The ReferrersUTMs component handles traffic source grouping by splitting metrics between referers and utms top-level tabs. Referrers contain subtabs for domain referers and full referer_urls, while UTM tabs iterate over UTM_TAGS_PLURAL_LIST.

const TAB_CONFIG: Record<
  TabId,
  {
    subtabs: Subtab[];
    defaultSubtab: Subtab;
    getSubtabLabel: (subtab: Subtab) => string;
  }
> = {
  referers: {
    subtabs: ["referers", "referer_urls"],
    defaultSubtab: "referers",
    getSubtabLabel: (subtab) => (subtab === "referers" ? "Domain" : "URL"),
  },
  utms: {
    subtabs: [...UTM_TAGS_PLURAL_LIST] as Subtab[],
    defaultSubtab: "utm_sources" as Subtab,
    getSubtabLabel: (subtab) =>
      SINGULAR_ANALYTICS_ENDPOINTS[subtab as UTM_TAGS_PLURAL].replace(
        "utm_",
        "",
      ),
  },
};

Sources: apps/web/ui/analytics/referrers-utms.tsx:18-40

For URL subtabs or standard referrers, item icons fetch favicons using GOOGLE_FAVICON_URL alongside getApexDomain. Direct traffic entries render a Link2 icon, and UTM rows load their specific parameter icon from UTM_PARAMETERS.

icon:
  isUtmTab && UTMTagIcon ? (
    <UTMTagIcon />
  ) : isDirect ? (
    <Link2 className="h-4 w-4" />
  ) : (
    <BlurImage
      src={`${GOOGLE_FAVICON_URL}${
        isRefererUrl
          ? getApexDomain(d[singularTabName])
          : d[singularTabName]
      }`}
      alt={d[singularTabName]}
      width={20}
      height={20}
      className="h-4 w-4 rounded-full"
    />
  ),

Sources: apps/web/ui/analytics/referrers-utms.tsx:152-169

Warning

Switching between the referers and utms primary tabs automatically resets the active subtab to the corresponding default defined in TAB_CONFIG and clears any selected filter items.

Sources: apps/web/ui/analytics/referrers-utms.tsx:53-56, apps/web/ui/analytics/referrers-utms.tsx:68-70

Partner program analytics pipelines

Program application metrics and event aggregations

Program applications and referral events are processed through specific workspace analytics routes and Prisma aggregations. The endpoint /api/program-applications/analytics handles queries for application metrics, supporting distinct group-by configurations including absolute counts, groupings by referralSource or country, partner-specific breakdowns, and timeseries intervals.

const aggregations = {
  _count: {
    visitedAt: true,
    startedAt: true,
    submittedAt: true,
    approvedAt: true,
    rejectedAt: true,
  },
} as const;

Sources: apps/web/app/ee/api/program-applications/analytics/route.ts:27-35

Application event queries evaluate specific event stages through dedicated query hooks and parse stage identifiers (visited, started, submitted, approved) alongside view options like timeseries and funnel.

Stage IdentifierMatching Event FieldDescription
visitedvisitedAtInitial visit tracking on the partner program page
startedstartedAtApplication form interaction initiation
submittedsubmittedAtCompleted application submission
approvedapprovedAtProgram manager approval of partner application

Sources: apps/web/app/ee/api/program-applications/analytics/route.ts:27-35, apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts:9-14

Note

When executing partner-specific application aggregations via byPartnerId, the system resolves partner records by filtering IDs that maintain active program enrollments in the target programId.

Sources: apps/web/app/ee/api/program-applications/analytics/route.ts:174-187

Commissions and partner analytics routing

Partner performance and commission metrics are handled by /api/partners/analytics and partner profile analytics routes, aligning tracking data with earnings aggregations. Depending on the groupBy parameter (count, timeseries, or top_links), the route queries the Commission table using Prisma raw queries or aggregate functions with status filters (pending, processed, paid) and types such as sale or referral.

if (groupBy === "count") {
  const earnings = await prisma.commission.aggregate({
    _sum: {
      earnings: true,
    },
    where: {
      type: "sale",
      amount: {
        gt: 0,
      },
      programId: programEnrollment.programId,
      partnerId: programEnrollment.partnerId,
      status: {
        in: ["pending", "processed", "paid"],
      },
      createdAt: {
        gte: startDate,
        lt: endDate,
      },
    },
  });
 
  return NextResponse.json({
    ...analytics,
    earnings: earnings._sum.earnings || 0,
  });
}

Sources: apps/web/app/ee/api/partners/analytics/route.ts:92-118

Partner profile program analytics endpoints evaluate large program thresholds and link counts before querying underlying analytics providers, returning fallback errors if Tinybird execution exceeds timeout thresholds.

Parameter / ConstantPurposeContext / Condition
LARGE_PROGRAM_IDSIdentifies high-volume programsChecks program eligibility for optimized analytics aggregation
LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTSCommission thresholdMinimum commission cents required for detailed analytics queries
MAX_PARTNER_LINKS_FOR_LOCAL_FILTERINGLink optimization thresholdDetermines whether to query by partnerId or specific linkId array

Sources: apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:7-10, apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:86-96

Warning

If Tinybird times out after 30 seconds during heavy partner analytics queries, the API throws a bad_request DubApiError instructing the UI to prompt a page refresh, preventing unhandled 500 server errors.

Sources: apps/web/app/ee/api/partner-profile/programs/programId/analytics/route.ts:102-109

Network referrals timeseries routing

Network referral timeseries data is aggregated via /api/partner-profile/referrals/timeseries, which requires partners to maintain an approved or trusted network status. The pipeline executes concurrent raw SQL queries against Partner and Commission tables using timezone conversion and date formatting maps.

const [partnersResult, earningsResult] = await Promise.all([
  prisma.$queryRaw<{ start: string; partners: number }[]>(
    Prisma.sql`
      SELECT
        DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start,
        COUNT(*) AS partners
      FROM Partner
      WHERE
        referredByPartnerId = ${partner.id}
        AND createdAt >= ${startDate}
        AND createdAt < ${endDate}
      GROUP BY start
      ORDER BY start ASC
    `,
  ),
 
  prisma.$queryRaw<{ start: string; earnings: number }[]>(
    Prisma.sql`
      SELECT
        DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start,
        SUM(earnings) AS earnings
      FROM Commission
      WHERE
        partnerId = ${partner.id}
        AND programId = ${NETWORK_PROGRAM_ID}
        AND type = ${CommissionType.referral}
        AND status in ('pending', 'processed', 'paid')
        AND createdAt >= ${startDate}
        AND createdAt < ${endDate}
      GROUP BY start
      ORDER BY start ASC
    `,
  ),
]);

Sources: apps/web/app/ee/api/partner-profile/referrals/timeseries/route.ts:40-73

Public and shared dashboard delivery

Password verification and dashboard page rendering

Shared analytics views are accessed via dynamic dashboard identifiers at /share/[dashboardId]. The page renderer fetches dashboard metadata using getDashboard, inspecting whether the targeted resource is tied to a specific link or folder. If no valid link or folder association exists, the server triggers a 404 response.

export default async function DashboardPage(props: {
  params: Promise<{ dashboardId: string }>;
}) {
  const params = await props.params;
  const data = await getDashboard({ id: params.dashboardId });
 
  // if the dashboard, link, or folder doesn't exist
  if (!data?.link && !data?.folder) {
    notFound();
  }

Sources: apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:31-40

When a dashboard enforces password protection, the server compares the cookie value stored under dub_password_[dashboardId] against the password hash configured on the dashboard record. If the cookie does not match, the page renders an authentication view featuring a decorative grid, an aurora gradient background, and a DashboardPasswordForm component.

if (
  data.password &&
  (await cookies()).get(`dub_password_${params.dashboardId}`)?.value !==
    data.password
) {
  return (
    <main className="relative min-h-[100dvh]">
      <div className="absolute inset-0 isolate overflow-hidden bg-neutral-50">
        {/* Grid */}
        <div
          className={cn(
            "absolute inset-y-0 left-1/2 w-[1200px] -translate-x-1/2",
            "[mask-composite:intersect] [mask-image:linear-gradient(black,transparent_320px),linear-gradient(90deg,transparent,black_5%,black_95%,transparent)]",
          )}
        >
          <Grid
            cellSize={60}
            patternOffset={[0.75, 0]}
            className="text-neutral-200"
          />
        </div>
 
        <AuroraGradient />
      </div>
 
      <div className="relative flex min-h-[100dvh] w-full items-center justify-center px-4 pb-12 pt-20">
        <a
          href="https://dub.co"
          target="_blank"
          rel="noopener noreferrer"
          className="absolute left-1/2 top-4 -translate-x-1/2"
        >
          <Wordmark className="h-8" />
        </a>
 
        <div className="animate-slide-up-fade flex w-full max-w-[320px] flex-col items-center [--offset:10px] [animation-duration:1s] [animation-fill-mode:both]">
          <div className="flex size-8 items-center justify-center rounded-md bg-neutral-200/60">
            <ShieldKeyhole className="size-[18px] text-neutral-800" />
          </div>
          <h1 className="mt-4 text-center text-xl font-semibold text-neutral-900">
            Enter Password
          </h1>
          <p className="mt-1 text-center text-base font-medium text-neutral-500">
            This dashboard is password protected.
          </p>
          <div className="mt-10 w-full">
            <DashboardPasswordForm />
          </div>
        </div>
      </div>
    </main>
  );
}

Sources: apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:42-94

Warning

If a shared dashboard requires a password and the matching cookie dub_password_[dashboardId] is absent or incorrect, the server bypasses the analytics component entirely and returns an unauthorized password entry view.

Sources: apps/web/app/app.dub.co/share/share/dashboardId/page.tsx:42-46

REST analytics endpoint authentication

The backend route /api/analytics/dashboard validates incoming query parameters via parseAnalyticsQuery, checking for domain, key, or folderId filters. Folders and links are looked up in Prisma to verify whether they expose a public dashboard configuration and whether a password is required.

export const GET = async (req: Request) => {
  try {
    const searchParams = getSearchParams(req.url);
    const parsedParams = parseAnalyticsQuery(searchParams);
 
    const {
      domain: domainFilter,
      key,
      folderId: folderIdFilter,
      interval,
      start,
      end,
    } = parsedParams;
 
    // Extract string values for specific link/folder lookup
    const domain = getFirstFilterValue(domainFilter);
    const folderId = getFirstFilterValue(folderIdFilter);
 
    if ((!domain || !key) && !folderId) {
      throw new DubApiError({
        code: "bad_request",
        message: "Missing domain/key or folderId query parameters",
      });
    }

Sources: apps/web/app/api/analytics/dashboard/route.ts:17-40

Entity TypeLookup StrategyPassword EnforcementError Code on Missing Dashboard
folderIdprisma.folder.findUniqueChecked via assertDashboardPasswordforbidden
demoLinkMatched against DUB_DEMO_LINKSNone (public demo)N/A
linkprisma.link.findUnique via domain_keyChecked via assertDashboardPasswordforbidden

Sources: apps/web/app/api/analytics/dashboard/route.ts:44-142

The helper function assertDashboardPassword evaluates the dashboard.password property against the cookie dub_password_$dashboard.id. If validation fails, a DubApiError with code unauthorized is thrown.

async function assertDashboardPassword(dashboard: {
  id: string;
  password: string | null;
}) {
  if (!dashboard.password) {
    return;
  }
 
  const cookiePassword = (await cookies()).get(
    `dub_password_${dashboard.id}`,
  )?.value;
 
  if (cookiePassword !== dashboard.password) {
    throw new DubApiError({
      code: "unauthorized",
      message: "This dashboard is password protected",
    });
  }
}

Sources: apps/web/app/api/analytics/dashboard/route.ts:204-222

Dataset and event CSV exports

Analytics ZIP archive generation

Exporting comprehensive analytics datasets is handled by the server route /api/analytics/export, which returns a compressed ZIP archive containing multiple CSV breakdowns. The route enforces request rate limits via RATELIMIT_POLICIES.analyticsExport keyed to the workspace ID, and checks short link usage constraints using throwIfClicksUsageExceeded.

await assertRateLimit({
  policy: RATELIMIT_POLICIES.analyticsExport,
  identifier: workspace.id,
});
 
throwIfClicksUsageExceeded(workspace);

Sources: apps/web/app/api/analytics/export/route.ts:26-31

Query parameters are parsed using parseAnalyticsQuery. If an external ID, domain, or key filter is provided without an explicit link ID, the backend resolves the target link using getLinkOrThrow to scope the parameters accurately. Date ranges are validated against plan capabilities via assertValidDateRangeForPlan, and workspace-level permission requirements enforce analytics.read.

const parsedParams = parseAnalyticsQuery(searchParams);
 
let {
  interval,
  start,
  end,
  folderId,
  domain,
  key,
  linkId,
  externalId,
  programId,
} = parsedParams;
 
let programStartedAt: Date | null | undefined = null;
if (programId) {
  const workspaceProgramId = getDefaultProgramIdOrThrow(workspace);
  if (programId !== workspaceProgramId) {
    throw new DubApiError({
      code: "forbidden",
      message: `Program ${programId} does not belong to workspace ${prefixWorkspaceId(workspace.id)}.`,
    });
  }
  const program = await getProgramOrThrow({
    workspaceId: workspace.id,
    programId,
  });
  programStartedAt = program.startedAt;
}
 
let folderIdToVerify = getFirstFilterValue(folderId);
if (!linkId && (externalId || (domain && key))) {
  const link = await getLinkOrThrow({
    workspaceId: workspace.id,
    linkId,
    externalId,
    domain: getFirstFilterValue(domain),
    key,
  });
 
  parsedParams.linkId = {
    operator: "IS",
    sqlOperator: "IN",
    values: [link.id],
  };
 
  parsedParams.domain = undefined;
 
  if (link.folderId && !folderIdToVerify) {
    folderIdToVerify = link.folderId;
  }
}
 
if (folderIdToVerify) {
  await verifyFolderAccess({
    workspace,
    userId: session.user.id,
    folderId: folderIdToVerify,
    requiredPermission: "folders.read",
  });
}
 
assertValidDateRangeForPlan({
  plan: workspace.plan,
  dataAvailableFrom: workspace.createdAt,
  interval,
  start,
  end,
});

Sources: apps/web/app/api/analytics/export/route.ts:33-101

The underlying archive compilation is delegated to exportAnalyticsToZip, incorporating conversion tracking capabilities where permitted by the workspace plan. The resulting data is streamed back with a Content-Type header of application/zip and a default file attachment name.

const zipData = await exportAnalyticsToZip({
  params: analyticsParams,
  workspaceId: workspace.id,
  useComposite: canTrackConversions,
  skipTopLinksForSingleLink: hasExactlyOneLinkIdFilter(parsedParams.linkId),
  getDataAvailableFrom: (endpoint) =>
    endpoint === "timeseries"
      ? programStartedAt ?? workspace.createdAt
      : undefined,
  ...(programId && {
    formatRows: formatProgramAnalyticsForExport,
  }),
});
 
return new Response(zipData as unknown as BodyInit, {
  headers: {
    "Content-Type": "application/zip",
    "Content-Disposition": "attachment; filename=analytics_export.zip",
  },
});

Sources: apps/web/app/api/analytics/export/route.ts:109-128

Partner profile program analytics follow a parallel export path at /api/partner-profile/programs/[programId]/analytics/export, utilizing partner authentication rules and applying distinct rate-limit policies.

Sources: apps/web/app/ee/api/partner-profile/programs/programId/analytics/export/route.ts:26-31

Raw event CSV exports and threshold queuing

Exporting individual telemetry events to CSV is managed by the EE events export route at /api/events/export. This endpoint requires business, advanced, or enterprise plans along with analytics.read permissions, and enforces a strict threshold where requests exceeding MAX_EVENTS_TO_EXPORT (set to 1000) are offloaded to background execution via QStash.

const MAX_EVENTS_TO_EXPORT = 1000;
 
const exportQuerySchema = z
  .object({
    columns: z
      .string()
      .transform((c) => c.split(","))
      .pipe(z.string().array()),
  })
  .passthrough();
 
// GET /api/events/export – export events to CSV (with async support if >1000 events)
export const GET = withWorkspace(

Sources: apps/web/app/ee/api/events/export/route.ts:25-37

Condition / MetricThreshold ValueHandling MechanismResponse Status
MAX_EVENTS_TO_EXPORT1000 eventsSynchronous CSV generation for counts within limit200 OK
Volume > threshold> 1000 eventsPublish background job to /api/cron/export/events/workspace via QStash202 Accepted

Sources: apps/web/app/ee/api/events/export/route.ts:25-149

When the event count is at or below the threshold, raw events are retrieved via getEvents, mapped against specified column accessors and names, converted into comma-separated values using convertToCSV, and returned as an attachment.

const response = await getEvents({
  ...parsedParams,
  event,
  workspaceId: workspace.id,
  limit: MAX_EVENTS_TO_EXPORT,
  dataAvailableFrom,
});
 
const data = response.map((row) =>
  Object.fromEntries(
    columns.map((c) => [
      eventsExportColumnNames?.[c] ?? capitalize(c),
      eventsExportColumnAccessors[c]?.(row) ?? row?.[c],
    ]),
  ),
);
 
const csvData = convertToCSV(data);
 
return new Response(csvData, {
  headers: {
    "Content-Type": "application/csv",
    "Content-Disposition": `attachment; filename=${event}_export.csv`,
  },
});

Sources: apps/web/app/ee/api/events/export/route.ts:151-175

Caution

Requesting an event export that exceeds 1,000 matching items causes the server to immediately short-circuit synchronous generation, dispatch a background worker payload via QStash, and return an empty 202 Accepted JSON response instead of a downloadable file.

Sources: apps/web/app/ee/api/events/export/route.ts:137-149

Client-side export initiation

The client-side trigger for downloading analytics packages is encapsulated within AnalyticsExportButton. The component reads workspace context parameters via AnalyticsContext to determine whether to call the standard workspace export route or a partner-specific export path.

export function AnalyticsExportButton({
  setOpenPopover,
}: {
  setOpenPopover: Dispatch<SetStateAction<boolean>>;
}) {
  const [loading, setLoading] = useState(false);
  const { queryString, baseApiPath, partnerPage } =
    useContext(AnalyticsContext);
 
  async function exportData() {
    setLoading(true);
    try {
      const exportPath = partnerPage
        ? `${baseApiPath}/export`
        : "/api/analytics/export";

Sources: apps/web/ui/analytics/analytics-export-button.tsx:7-22

Upon user interaction, the component executes a GET request against the resolved export route with active query parameters, handles error payloads by extracting nested API error messages, and programmatically generates an object URL blob download named with an ISO timestamp.

    const response = await fetch(`${exportPath}?${queryString}`, {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    });
 
    if (!response.ok) {
      let message = response.statusText;
 
      try {
        const body = await response.json();
        if (body?.error?.message) {
          message = body.error.message;
        }
      } catch {
        // ignore JSON parse errors
      }
 
      throw new Error(message);
    }
 
    const blob = await response.blob();
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `Dub Analytics Export - ${new Date().toISOString()}.zip`;
    a.click();
    window.URL.revokeObjectURL(url);
  } catch (error) {
    throw error instanceof Error ? error : new Error(String(error));
  } finally {
    setLoading(false);
  }
}

Sources: apps/web/ui/analytics/analytics-export-button.tsx:23-57

Sources: apps/web/app/api/analytics/export/route.ts:26-128, apps/web/app/ee/api/events/export/route.ts:37-149, apps/web/ui/analytics/analytics-export-button.tsx:23-51

Was this page helpful?