Skip to main content
doc0blade47/dubdocumentation home
Sign in

Link Dispatch

Last updated Oct 12, 2026
<details>
<summary>Relevant source files</summary>

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

- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/middleware.ts)
- [apps/web/app/ee/api/track/visit/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/visit/route.ts)
- [apps/web/app/api/analytics/dashboard/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/analytics/dashboard/route.ts)
- [apps/web/app/domain/expired/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/expired/page.tsx)
- [apps/web/lib/middleware/utils/crawl-bitly.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.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/domain/notfound/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/notfound/page.tsx)
- [apps/web/app/api/links/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/route.ts)
- [apps/web/app/ee/api/cron/import/bitly/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/bitly/route.ts)
- [apps/web/app/cloaked/url/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/cloaked/%5Burl%5D/page.tsx)
- [apps/web/lib/middleware/app.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/app.ts)
- [apps/web/lib/api/links/process-link.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/process-link.ts)
- [apps/web/lib/middleware/utils/get-final-url.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/get-final-url.ts)
- [apps/web/app/domain/banned/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/banned/page.tsx)
- [apps/web/app/domain/key/inspect/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx)
- [apps/web/app/api/callback/bitly/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/bitly/route.ts)
- [apps/web/app/password/linkId/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx)
- [apps/web/app/api/links/iframeable/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/iframeable/route.ts)
- [apps/web/app/api/providers/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/providers/route.ts)
- [apps/web/app/api/links/metatags/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/metatags/route.ts)
- [apps/web/app/app.dub.co/share/share/dashboardId/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(share)/share/%5BdashboardId%5D/page.tsx)
- [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/middleware/create-link.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/create-link.ts)
- [apps/web/lib/middleware/partners.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/partners.ts)
- [apps/web/lib/integrations/slack/commands.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/slack/commands.ts)
- [apps/web/lib/middleware/embed.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/embed.ts)
- [apps/web/lib/middleware/new-link.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/new-link.ts)
</details>

## Overview

Link dispatch powers the core request processing engine responsible for handling incoming short links across custom domains and root namespaces at the edge. By combining low-latency caching with edge middleware, Dub evaluates request criteria, resolves target destinations, and handles specialized routing rules such as conditional targeting, link cloaking, password challenges, and automated fallback behavior.

Sources: [apps/web/lib/middleware/link.ts:43-123](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L43-L123), [apps/web/middleware.ts:34-88](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/middleware.ts#L34-L88)

## Edge Link Resolution Pipeline

### Domain and Key Normalization

Incoming requests to short links and custom domains first enter the global edge middleware pipeline defined in `apps/web/middleware.ts`. The router parses the request to extract the target domain, path, and slug key, initializes Axiom logging via `transformMiddlewareRequest`, and dispatches requests across specialized handlers. Root management consoles, API endpoints, partner portals, and well-known files are routed accordingly, while standard link lookups fall through to `LinkMiddleware`.

\`\`\`typescript
export default async function middleware(req: NextRequest, ev: NextFetchEvent) {
  const { domain, path, key, fullKey } = parse(req);

  // Axiom logging
  logger.info(...transformMiddlewareRequest(req));
  ev.waitUntil(logger.flush());

  // for app.dub.co
  if (isAppHostname(domain)) {
    return AppMiddleware(req);
  }

  // for api.dub.co
  if (API_HOSTNAMES.has(domain)) {
    return ApiMiddleware(req);
  }

  // for public stats pages (e.g. d.to/stats/try -> rewrite to [/domain]/[key]/stats)
  if (path.startsWith("/stats/")) {
    return NextResponse.rewrite(
      new URL(
        `/${domain}/${encodeURIComponent(path.replace("/stats/", ""))}/stats`,
        req.url,
      ),
    );
  }

  // for .well-known routes
  if (path.startsWith("/.well-known/")) {
    const file = path.split("/.well-known/").pop();
    if (file && supportedWellKnownFiles.includes(file)) {
      return NextResponse.rewrite(
        new URL(`/wellknown/${domain}/${file}`, req.url),
      );
    }
  }

  // default redirects for dub.sh
  if (domain === "dub.sh" && DEFAULT_REDIRECTS[key]) {
    return NextResponse.redirect(DEFAULT_REDIRECTS[key]);
  }

  if (ADMIN_HOSTNAMES.has(domain)) {
    return AdminMiddleware(req);
  }

  if (PARTNERS_HOSTNAMES.has(domain)) {
    return PartnersMiddleware(req);
  }

  if (isValidUrl(fullKey)) {
    return CreateLinkMiddleware(req);
  }

  return LinkMiddleware(req, ev);
\`\`\`
Sources: [apps/web/middleware.ts:34-88](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/middleware.ts#L34-L88)

When a request reaches `LinkMiddleware`, the domain and original key are extracted using `parse(req)`. If no domain is present, the middleware immediately yields control via `NextResponse.next()`. Otherwise, the key undergoes ASCII punycode encoding. Case sensitivity is evaluated against `isCaseSensitiveDomain(domain)`; if the domain is not case-sensitive, the key is converted to lowercase.

\`\`\`typescript
export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
  let { domain, fullKey: originalKey, fullPath, searchParamsObj } = parse(req);

  if (!domain) {
    return NextResponse.next();
  }

  // encode the key to ascii
  // links on Dub are case insensitive by default
  let key = punyEncode(originalKey);

  if (!isCaseSensitiveDomain(domain)) {
    key = key.toLowerCase();
  }
\`\`\`
Sources: [apps/web/lib/middleware/link.ts:43-56](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L43-L56)

Inspect mode is triggered if the key ends with a `+` character. When active, the trailing `+` is stripped to query the underlying resource. If the resulting key is an empty string, it is reassigned to `_root` to resolve root domain link configurations.

> [!WARNING]
> Requests containing unsupported keys (such as `.php` extensions associated with excessive automated bot traffic) bypass normal resolution entirely. They are redirected to the root domain with a `?dub-no-track=1` query parameter and tagged with `X-Robots-Tag: googlebot: noindex` to prevent crawler indexing.

\`\`\`typescript
const inspectMode = key.endsWith("+");
// if inspect mode is enabled, remove the trailing `+` from the key
if (inspectMode) {
  key = key.slice(0, -1);
}

// if key is empty string, set to _root (root domain link)
if (key === "") {
  key = "_root";
}

const STATIC_PAGES_CACHE_HEADERS = {
  "Vercel-CDN-Cache-Control": "public, s-maxage=86400",
  "Vercel-Cache-Tag": linkCache._createStaticPagesCacheKeys({
    domain,
    key: originalKey || "_root",
  }),
};

// we don't support .php links (too much bot traffic)
// hence we redirect to the root domain and add `dub-no-track` header to avoid tracking bot traffic
if (isUnsupportedKey(key)) {
  return NextResponse.redirect(new URL("/?dub-no-track=1", req.url), {
    headers: {
      ...DUB_HEADERS,
      "X-Robots-Tag": "googlebot: noindex",
    },
    status: 302,
  });
}
\`\`\`
Sources: [apps/web/lib/middleware/link.ts:58-87](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L43-L56)

### Cache Lookups and Edge Fallback

The pipeline queries `linkCache.get({ domain, key })` to retrieve cached link metadata from Redis. If Redis encounters a failure, `redisFailOver` is set, and click tracking jobs are safely skipped to prevent edge execution timeouts.

\`\`\`typescript
let { cachedLink, redisFailOver } = await linkCache.get({ domain, key });
let isPartnerLink = Boolean(cachedLink?.programId && cachedLink?.partnerId);

// skip click tracking during Redis failover to avoid timeouts
const recordClick = redisFailOver
  ? async () => {
      console.log("Redis failover detected, skipping click tracking...");
      return;
    }
  : recordClickJob;

\`\`\`
Sources: [apps/web/lib/middleware/link.ts:89-99](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L89-L99)

If the link cache misses, the middleware invokes `getLinkViaEdge({ domain, key })` to fetch record data from PlanetScale. If the record remains unfound and the domain equals `buff.ly`, the request is handed off to the On-Demand Bitly Migration Crawling handler. For all other unknown domains and keys, the middleware responds with a not-found rewrite to `/${domain}/notfound` accompanied by static Vercel CDN cache control headers (`public, s-maxage=86400`) and cache tags generated via `linkCache._createStaticPagesCacheKeys`.

\`\`\`typescript
if (!cachedLink) {
  let linkData = await getLinkViaEdge({
    domain,
    key,
  });

  if (!linkData) {
    if (domain === "buff.ly") {
      return await crawlBitly(req, ev);
    }

    return NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), {
      headers: {
        ...DUB_HEADERS,
        ...STATIC_PAGES_CACHE_HEADERS,
      },
    });
  }
\`\`\`
Sources: [apps/web/lib/middleware/link.ts:100-117](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L100-L117)

When edge lookup succeeds, partner status is evaluated from `programId` and `partnerId`, and the raw payload is formatted via `formatRedisLink`. Non-partner links are cached synchronously in the background using `ev.waitUntil()`. Partner links fetch additional enrollment and discount details via `getPartnerEnrollmentInfo` before backfilling the cache.

\`\`\`typescript

  isPartnerLink = Boolean(linkData.programId && linkData.partnerId);

  // format link to fit the RedisLinkProps interface
  cachedLink = formatRedisLink(linkData as any);

  ev.waitUntil(
    (async () => {
      if (!isPartnerLink) {
        await linkCache.set(linkData as any, { skipRevalidateTag: true });
        return;
      }

      const { partner, discount } = await getPartnerEnrollmentInfo({
        programId: linkData.programId,
        partnerId: linkData.partnerId,
        linkId: linkData.id,
      });

      // we'll use this data on /track/click
      await linkCache.set(
        {
          ...(linkData as any),
          ...(partner && { partner }),
          ...(discount && { discount }),
        },
        { skipRevalidateTag: true },
      );
    })(),
  );
}
\`\`\`
Sources: [apps/web/lib/middleware/link.ts:118-148](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L118-L148)

## Destination Routing and Targeting Rules

### A/B Testing Variants and URL Resolution

When a link request successfully resolves, `LinkMiddleware` extracts targeting and routing properties from the cached link record (`cachedLink`), including `geo` (geo-targeting rules), `ios`, `android`, `testVariants`, and `testCompletedAt`. Experiment variant selection is evaluated by passing `testVariants` and `testCompletedAt` to `resolveABTestURL`. If an active A/B test variant is selected, `testUrl` overrides the default destination; otherwise, resolution falls back to `cachedLink.url`.

\`\`\`typescript
const {
  id: linkId,
  password,
  trackConversion,
  geo: geoTargeting,
  proxy,
  rewrite,
  expiresAt,
  disabledAt,
  ios,
  android,
  expiredUrl,
  doIndex,
  testVariants,
  testCompletedAt,
  projectId: workspaceId,
} = cachedLink;

const testUrl = await resolveABTestURL({
  testVariants,
  testCompletedAt,
});

const url = testUrl || cachedLink.url;

\`\`\`
Sources: [apps/web/lib/middleware/link.ts:150-174](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L150-L174)

### Final URL Construction and Query Parameter Handling

The final destination URL is assembled using `getFinalUrl`, which injects tracking parameters, attribution identifiers, and handles platform-specific redirect parameters. If a `clickId` is present and `dub_client_reference_id` is set to `"1"` (custom query parameter configuration for Stripe payment links), `client_reference_id` is assigned `dub_id_${clickId}` and `dub_client_reference_id` is deleted. If `dub_client_reference_id` is not active and tracking is enabled, `dub_id` is appended to the query string.

\`\`\`typescript
export const getFinalUrl = (
  url: string,
  {
    req,
    clickId,
    via,
  }: {
    req: NextRequest;
    clickId?: string;
    via?: string;
  },
) => {
  // query is the query string (e.g. d.to/github?utm_source=twitter -> ?utm_source=twitter)
  const searchParams = req.nextUrl.searchParams;

  const urlObj = new URL(url);

  if (via) {
    urlObj.searchParams.set("via", via);
  }

  if (clickId) {
    /*
       custom query param for stripe payment links:
       - if there is a clickId and dub_client_reference_id is 1
       - then set client_reference_id to dub_id_${clickId} and drop the dub_client_reference_id param
       - our Stripe integration will then detect `dub_id_${clickId}` as the dubClickId in the `checkout.session.completed` webhook
       - @see: https://github.com/dubinc/dub/blob/main/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts
    */
    if (urlObj.searchParams.get("dub_client_reference_id") === "1") {
      urlObj.searchParams.set("client_reference_id", `dub_id_${clickId}`);
      urlObj.searchParams.delete("dub_client_reference_id");

      // if there's a clickId and no dub-no-track search param, then add clickId to the final url
      // reasoning: if you're skipping tracking, there's no point in passing the clickId anyway
    } else if (!searchParams.has("dub-no-track")) {
      urlObj.searchParams.set("dub_id", clickId);
    }
  }
\`\`\`
Sources: [apps/web/lib/middleware/utils/get-final-url.ts:12-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/get-final-url.ts#L12-L50)

For mobile attribution and partner networks, `getFinalUrl` injects network-specific parameters. AppsFlyer tracking URLs receive hardcoded parameter `pid=dubinc_int`, along with `clickid`, `c`, and `af_siteid` when available. Singular tracking links populate `cl`, `ua`, and `ip`, and polyfill `wpcn` and `wpcl` parameters if set to `{via}` or `{dub_id}` placeholders. Google Play Store URLs decode existing `referrer` parameters and append `deepLink` values containing the short link and search parameters. Finally, incoming search parameters from the request are merged into the target URL, stripping internal control parameters such as `dub-no-track`, `redir_url`, `qr`, and `skip_deeplink_preview`.

\`\`\`typescript
// for AppsFlyer tracking links
if (isAppsFlyerTrackingUrl(url)) {
  // set hardcoded query params
  urlObj.searchParams.set("pid", "dubinc_int");

  if (clickId) {
    urlObj.searchParams.set("clickid", clickId);
  }

  // set dynamic params (if not exist)
  if (!urlObj.searchParams.has("c") && via) {
    urlObj.searchParams.set("c", via);
  }

  if (!urlObj.searchParams.has("af_siteid") && via) {
    urlObj.searchParams.set("af_siteid", via);
  }
}

// for Singular tracking links
if (isSingularTrackingUrl(url)) {
  const ua = userAgent(req);
  const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP;
  urlObj.searchParams.set("cl", clickId ?? "");
  urlObj.searchParams.set("ua", ua?.ua ?? "");
  urlObj.searchParams.set("ip", ip ?? "");
  // Polyfill wpcn & wpcl params for Singular integration
  const wpcn = urlObj.searchParams.get("wpcn");
  const wpcl = urlObj.searchParams.get("wpcl");

  if (wpcn && wpcn === "{via}") {
    urlObj.searchParams.set("wpcn", via ?? "");
  }

  if (wpcl && wpcl === "{dub_id}") {
    urlObj.searchParams.set("wpcl", clickId ?? "");
  }
}

// for Google Play Store links
if (isGooglePlayStoreUrl(url)) {
  const { shortLink, searchParamsString } = parse(req);
  const existingReferrer = urlObj.searchParams.get("referrer");

  const referrerSearchParam = new URLSearchParams(
    existingReferrer ? decodeURIComponent(existingReferrer) : "",
  );
  referrerSearchParam.set("deepLink", `${shortLink}${searchParamsString}`);
  urlObj.searchParams.set("referrer", referrerSearchParam.toString());
}

// if there are no query params, then return the target url as is (no need to parse it)
// @ts-ignore – until https://github.com/microsoft/TypeScript/issues/54466 is fixed
if (searchParams.size === 0) return urlObj.toString();

// if searchParams (type: `URLSearchParams`) has the same key as target url, then overwrite it
for (const [key, value] of searchParams) {
  // we will pass everything except internal query params (dub-no-track and redir_url)
  if (["dub-no-track", REDIRECTION_QUERY_PARAM].includes(key)) continue;
  urlObj.searchParams.set(key, value);
}

// remove qr param from the final url if the value is "1" (only used for detectQr function)
if (urlObj.searchParams.get("qr") === "1") {
  urlObj.searchParams.delete("qr");
}

// remove skip_deeplink_preview param from the final url (only used for internal redirection behavior)
if (urlObj.searchParams.get("skip_deeplink_preview") === "1") {
  urlObj.searchParams.delete("skip_deeplink_preview");
}

return urlObj.toString();
\`\`\`
Sources: [apps/web/lib/middleware/utils/get-final-url.ts:52-124](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/get-final-url.ts#L52-L124)

### App Deep Link Previews and Platform Handlers

For mobile deep links, requests to short links on domains with configured deep link rules evaluate client device properties using `userAgent`. The platform state is categorized as either `ios` or `android` based on the operating system name.

\`\`\`tsx
// Detect language from Accept-Language header
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
const language = getLanguage(acceptLanguage);
const t = getTranslations(language);

const ua = userAgent({ headers: headersList });
const platform: "ios" | "android" =
  ua.os?.name === "Android" ? "android" : "ios";

\`\`\`
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:52-61](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)

The deep link resolution handler queries the database for domain and key assets, including short domain associations such as `appleAppSiteAssociation`, `assetLinks`, and `deepviewData`. If the target domain lacks deep link asset configuration for the user's platform, the preview is bypassed, redirecting immediately to the platform-specific URL (`link.ios` or `link.android`) or falling back to the canonical URL (`link.url`).

\`\`\`tsx
let link = await prisma.link.findUnique({
  where: {
    domain_key: {
      domain,
      key: encodedKey,
    },
  },
  select: {
    domain: true,
    key: true,
    shortLink: true,
    url: true,
    ios: true,
    android: true,
    shortDomain: {
      select: {
        appleAppSiteAssociation: true,
        assetLinks: true,
        deepviewData: true,
      },
    },
  },
});

// if the link doesn't exist, we redirect to the root domain link
if (!link) {
  redirect(`https://${domain}`);
}

const { appleAppSiteAssociation, assetLinks, deepviewData } =
  link.shortDomain;

// if the domain isn't set up for deep linking on the user's platform, skip
// the preview and forward to the platform-specific URL (or the canonical URL)
if (platform === "android") {
  if (!assetLinks || !deepviewData) {
    redirect(link.android ?? link.url);
  }
} else {
  if (!appleAppSiteAssociation || !deepviewData) {
    redirect(link.ios ?? link.url);
  }
}
\`\`\`
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:68-110](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)

> [!NOTE]
> On Android platforms, `androidPackageName` is extracted by validating asset links where the target namespace equals `android_app` and the package name satisfies the strict regex pattern `/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/`.

\`\`\`tsx
let androidPackageName: string | null = null;

if (platform === "android" && Array.isArray(link.shortDomain.assetLinks)) {
  for (const assetLink of link.shortDomain.assetLinks as Array<AssetLink>) {
    const packageName = assetLink?.target?.package_name;

    if (
      assetLink?.target?.namespace === "android_app" &&
      typeof packageName === "string" &&
      ANDROID_PACKAGE_NAME_REGEX.test(packageName)
    ) {
      androidPackageName = packageName;
      break;
    }
  }
}
\`\`\`
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:122-137](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)

## On-Demand Bitly Migration Crawling

### Dynamic Crawling and Cache Backfilling

When a link lookup via edge storage (`getLinkViaEdge`) yields no matching record for the `buff.ly` domain, the link dispatch middleware delegates handling to the `crawlBitly` utility. This service intercepts legacy references to fetch long-form destination URLs dynamically from the Bitly v4 API (`https://api-ssl.bitly.com/v4/bitlinks/{domain}/{key}`) using `BUFFER_BITLY_API_KEY`. 

\`\`\`typescript
export const crawlBitly = async (req: NextRequest, ev: NextFetchEvent) => {
  const { domain, fullKey: key } = parse(req);

  // bitly doesn't support the following characters: ` ~ , . < > ; ‘ : " / \ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # |
  // @see: https://support.bitly.com/hc/en-us/articles/360030780892-What-characters-are-supported-when-customizing-links
  const invalidBitlyKeyRegex = /[`~,.<>;':"/\\[\]^{}()=+!*@&$£?%#|]/;

  if (key && !invalidBitlyKeyRegex.test(key)) {
    const link = await fetchBitlyLink({ domain, key });
    if (link) {
\`\`\`
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:20-29](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L29)

> [!CAUTION]
> Keys containing invalid Bitly characters matching the regex `/[`~,.<>;':"/\\[\]^{}()=+!*@&$£?%#|]/` are rejected outright without querying the upstream API, immediately redirecting traffic to the default fallback landing page at `https://buffer.com`.

\`\`\`typescript
// bitly doesn't support the following characters: ` ~ , . < > ; ‘ : " / \ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # |
// @see: https://support.bitly.com/hc/en-us/articles/360030780892-What-characters-are-supported-when-customizing-links
const invalidBitlyKeyRegex = /[`~,.<>;':"/\\[\]^{}()=+!*@&$£?%#|]/;

if (key && !invalidBitlyKeyRegex.test(key)) {
…
return NextResponse.redirect("https://buffer.com", {
\`\`\`
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:23-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.ts#L23-L27), [apps/web/lib/middleware/utils/crawl-bitly.ts:71-71](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.ts#L71-L71)

### On-Demand Import and Workspace Assignment

Upon successfully retrieving link properties from Bitly, the long URL is sanitized via `getUrlFromStringIfValid`. If valid, Prisma persists the migrated record asynchronously using `ev.waitUntil` under designated system workspace and folder constants. Concurrently, click telemetry registers in Tinybird and workspace usage metrics publish to Upstash Redis streams.

\`\`\`typescript
const sanitizedUrl = getUrlFromStringIfValid(link.long_url);
if (sanitizedUrl) {
  console.log(
    `[Bitly] Creating link on-demand: ${domain}/${key} (createdAt: ${link.created_at})`,
  );
  const encodedKey = encodeKeyIfCaseSensitive({ domain, key });
  ev.waitUntil(
    prisma.link
      .create({
        data: {
          id: createId({ prefix: "link_" }),
          domain,
          key: encodedKey,
          url: sanitizedUrl,
          shortLink: linkConstructorSimple({ domain, key: encodedKey }),
          projectId: BUFFER_WORKSPACE_ID,
          userId: BUFFER_USER_ID,
          folderId: BUFFER_FOLDER_ID,
          createdAt: new Date(link.created_at),
        },
      })
      .then((data) =>
        Promise.allSettled([
          recordLink(data),
          publishWorkspaceLinksUsageEvent({
            workspaceId: BUFFER_WORKSPACE_ID,
            linksCount: 1,
            timestamp: new Date().toISOString(),
          }),
        ]),
      ),
  );
\`\`\`
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:30-61](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.ts#L30-L61)

| Constant Name | Source Value | Purpose / Entity Assignment |
| :--- | :--- | :--- |
| `BUFFER_WORKSPACE_ID` | `cm05wnnpo000711ztj05wwdbu` | Target workspace identifier for imported Bitly links |
| `BUFFER_USER_ID` | `cm05wnd49000411ztg2xbup0i` | Creator user identifier associated with migrated records |
| `BUFFER_FOLDER_ID` | `fold_1JNQBVZV8P0NA0YGB11W2HHSQ` | Default folder assignment for buffer-migrated assets |

\`\`\`typescript
const BUFFER_WORKSPACE_ID = "cm05wnnpo000711ztj05wwdbu";
const BUFFER_USER_ID = "cm05wnd49000411ztg2xbup0i";
const BUFFER_FOLDER_ID = "fold_1JNQBVZV8P0NA0YGB11W2HHSQ";
\`\`\`
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:15-17](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.ts#L15-L17)

### OAuth Callback and Token Exchange

Bulk migrations and user-authenticated synchronizations are managed via the Bitly OAuth callback route at `/api/callback/bitly`. This endpoint extracts user sessions, executes `bitlyOAuthProvider.exchangeCodeForToken`, and parses the resulting token payload.

\`\`\`typescript
export async function GET(req: Request) {
  try {
    const session = await getSession();

    if (!session?.user.id) {
      throw new DubApiError({
        code: "unauthorized",
        message: "Unauthorized.",
      });
    }

    const {
      token: response,
      contextId: { workspaceId, folderId },
    } = await bitlyOAuthProvider.exchangeCodeForToken<{
      workspaceId: string;
      folderId?: string;
    }>(req);
\`\`\`
Sources: [apps/web/app/api/callback/bitly/route.ts:10-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/bitly/route.ts#L10-L27)

> [!NOTE]
> If the OAuth response contains errors or fails verification, the request redirects unconditionally to the root application domain (`APP_DOMAIN`).

\`\`\`typescript
if (!response || response.includes("error")) {
  return NextResponse.redirect(APP_DOMAIN);
}
\`\`\`
Sources: [apps/web/app/api/callback/bitly/route.ts:29-31](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/bitly/route.ts#L29-L31)

Successfully validated access tokens are stored in Redis under `import:bitly:{workspaceId}`, and workspace slug queries determine the final dashboard redirect URL accompanied by query parameters specifying `import=bitly` and the active `folderId`.

\`\`\`typescript
const params = new URLSearchParams(response);

const [workspace, _] = await Promise.all([
  // get workspace slug from workspaceId
  prisma.project.findUnique({
    where: {
      id: workspaceId,
    },
    select: {
      slug: true,
    },
  }),

  // store access token in redis
  redis.set(`import:bitly:${workspaceId}`, params.get("access_token")),
]);

const queryParams = new URLSearchParams({
  import: "bitly",
  ...(folderId ? { folderId } : {}),
});

const redirectUrl = workspace
  ? `${APP_DOMAIN}/${workspace.slug}?${queryParams.toString()}`
  : APP_DOMAIN;

return NextResponse.redirect(redirectUrl);
\`\`\`
Sources: [apps/web/app/api/callback/bitly/route.ts:33-59](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/bitly/route.ts#L33-L59)

## Cloaking and Proxy Destination Rendering

### Cloaked URL Masking and Iframe Encapsulation

The route handler located at `apps/web/app/cloaked/[url]/page.tsx` renders cloaked destination URLs inside an HTML `iframe`. When requests hit the page, `getCloakedDestinationUrl` evaluates whether the incoming parameter needs secondary decoding, checking if the string matches the regex pattern `/^https?%3A/i` and safely applying `decodeURIComponent` if required.

\`\`\`tsx
function getCloakedDestinationUrl(param: string): string {
  // Next.js decodes dynamic route segments once. Only decode again when the
  // param is still fully encoded.
  if (/^https?%3A/i.test(param)) {
    try {
      return decodeURIComponent(param);
    } catch {
      return param;
    }
  }

  return param;
}
\`\`\`
Sources: [apps/web/app/cloaked/url/page.tsx:8-20](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/cloaked/%5Burl%5D/page.tsx#L8-L20)

\`\`\`tsx
export default async function CloakedPage(props: {
  params: Promise<{ url: string }>;
}) {
  const params = await props.params;
  const url = getCloakedDestinationUrl(params.url);

  return <iframe src={url} className="min-h-screen w-full border-none" />;
\`\`\`
Sources: [apps/web/app/cloaked/url/page.tsx:40-46](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/cloaked/%5Burl%5D/page.tsx#L40-L46)

> [!NOTE]
> The cloaked page component renders a full-screen, borderless iframe element containing the resolved destination URL while injecting specific Open Graph metadata tags.

\`\`\`tsx
return <iframe src={url} className="min-h-screen w-full border-none" />;
\`\`\`
Sources: [apps/web/app/cloaked/url/page.tsx:46-46](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/cloaked/%5Burl%5D/page.tsx#L46-L46)

### Open Graph Metadata Injection and Proxy Rendering

The proxy page located at `apps/web/app/[domain]/[key]/proxy/page.tsx` queries edge storage via `getLinkViaEdge` using the provided domain and key parameters. If the target record lacks proxy capabilities (`!data?.proxy`), the handler executes an immediate HTTP redirect to the destination URL or triggers `notFound()` if the link record is missing entirely.

\`\`\`tsx
export default async function ProxyPage(props: {
  params: Promise<{ domain: string; key: string }>;
}) {
  const params = await props.params;
  const domain = params.domain;
  const key = decodeURIComponent(params.key);

  const data = await getLinkViaEdge({ domain, key });

  // if the link doesn't exist
  if (!data) {
    notFound();

    // if the link does not have proxy enabled, redirect to the original URL
  } else if (!data?.proxy) {
    redirect(data.url);
  }
\`\`\`
Sources: [apps/web/app/domain/key/proxy/page.tsx:36-52](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx#L36-L52)

When proxy rendering is active, `generateMetadata` constructs custom Open Graph headers using `constructMetadata`, setting `noIndex: true` and mapping the favicon source to Google's favicon service combined with the apex domain extracted via `getApexDomain`.

\`\`\`tsx
export async function generateMetadata(props: {
  params: Promise<{ domain: string; key: string }>;
}) {
  const params = await props.params;
  const domain = params.domain;
  const key = decodeURIComponent(params.key); // key can potentially be encoded

  const data = await getLinkViaEdge({ domain, key });

  if (!data?.proxy) {
    return;
  }

  const apexDomain = getApexDomain(data.url);

  return constructMetadata({
    title: unescape(data.title || ""),
    description: unescape(data.description || ""),
    image: data.image,
    video: data.video,
    icons: `${GOOGLE_FAVICON_URL}${apexDomain}`,
    noIndex: true,
  });
}
\`\`\`
Sources: [apps/web/app/domain/key/proxy/page.tsx:11-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx#L11-L34)

### Metadata and Iframeable API Endpoints

Edge-runtime API route endpoints support external verification of link properties and metatag extraction. The metatags endpoint at `apps/web/app/api/links/metatags/route.ts` evaluates CORS headers for `.dub.co` origins, enforces IP rate limiting via `ratelimitOrThrow`, and sets cache headers allowing public caching for 300 seconds and Vercel CDN stale-while-revalidate for 86400 seconds.

\`\`\`typescript
export const runtime = "edge";

export async function GET(req: NextRequest) {
  try {
    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;
    }

    // Validate URL parameter
    const { url } = getUrlQuerySchema.parse({
      url: req.nextUrl.searchParams.get("url"),
    });

    // Rate limit by IP
    await ratelimitOrThrow(req, "metatags");

    // Get metatags
    const metatags = await getMetaTags(url);

    // Return response
    return NextResponse.json(
      {
        ...metatags,
        poweredBy: "Dub - The Modern Link Attribution Platform",
      },
      {
        headers: {
          ...corsHeaders,
          "Cache-Control": "public, max-age=300",
          "Vercel-CDN-Cache-Control":
            "s-maxage=3600, stale-while-revalidate=86400",
        },
      },
    );
\`\`\`
Sources: [apps/web/app/api/links/metatags/route.ts:7-47](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/metatags/route.ts#L7-L47)

| API Endpoint Path | Runtime | Rate Limit Key | Cache-Control Header |
| :--- | :--- | :--- | :--- |
| `apps/web/app/api/links/iframeable/route.ts` | `edge` | `iframeable` | *Not cached* |
| `apps/web/app/api/links/metatags/route.ts` | `edge` | `metatags` | `public, max-age=300` |

\`\`\`typescript
export const runtime = "edge";
\`\`\`
Sources: [apps/web/app/api/links/iframeable/route.ts:10-10](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/iframeable/route.ts#L10-L10)

\`\`\`typescript
export const runtime = "edge";
\`\`\`
Sources: [apps/web/app/api/links/metatags/route.ts:7-7](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/metatags/route.ts#L7-L7)

## Password Protection Gateways

### Password Gate Interception and Cookie Validation

Incoming requests to links configured with a password attribute are intercepted when the edge link resolver evaluates `cachedLink`. The password protection page at `apps/web/app/password/[linkId]/page.tsx` runs on the `edge` runtime with dynamic rendering enforced via `export const dynamic = "force-dynamic"`. When loaded, the component queries `prismaEdge.link.findUnique` to retrieve link properties including `id`, `domain`, `key`, `password`, `shortLink`, and workspace project details.

\`\`\`tsx
export const dynamic = "force-dynamic";
export const runtime = "edge";
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:11-12](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L11-L12)

\`\`\`tsx
export default async function PasswordProtectedLinkPage(props: {
  params: Promise<{ linkId: string }>;
}) {
  const params = await props.params;
  const link = await prismaEdge.link.findUnique({
    where: {
      id: params.linkId,
    },
    select: {
      id: true,
      domain: true,
      key: true,
      password: true,
      shortLink: true,
      project: {
        select: {
          name: true,
          logo: true,
          plan: true,
        },
      },
    },
  });
\`\`\`
Sources: [apps/web/app/password/[linkId]/page.tsx:58-80](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L80-L80)

> [!WARNING]
> If a link record is not found during the password gate lookup, the handler executes `notFound()` immediately, returning a 404 status before any challenge form is rendered.

\`\`\`tsx
if (!link) {
  notFound();
}
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:82-84](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L82-L84)

The verification logic checks whether `link.password` is empty or if the incoming cookie matching `dub_password_${link.id}` equals `link.password`. If validation succeeds, the user is redirected to `link.shortLink`. Otherwise, the edge renders a secure challenge interface containing project branding or a fallback lock icon.

\`\`\`tsx
if (
  !link.password ||
  (await cookies()).get(`dub_password_${link.id}`)?.value === link.password
) {
  redirect(link.shortLink);
}
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:86-91](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L86-L91)

\`\`\`tsx
return (
  <>
    <NewBackground />
    <main className="relative mb-10 flex w-screen flex-col items-center">
      <Wordmark className="mt-6 h-8" />
      <div className="z-10 mt-8 w-full max-w-[400px] overflow-hidden rounded-2xl border border-neutral-200 shadow-sm md:mt-24">
        <div className="flex flex-col items-center justify-center gap-3 border-b border-neutral-200 bg-white px-4 py-6 text-center">
          {link.project?.logo ? (
            <BlurImage
              src={link.project.logo}
              alt={link.project.name}
              width={48}
              height={48}
              className="size-12 rounded-full"
            />
          ) : (
            <div className="flex size-12 items-center justify-center rounded-full bg-neutral-100">
              <Lock className="size-4 text-neutral-600" />
            </div>
          )}
          <h3 className="mt-1 text-lg font-semibold">Password required</h3>
          <p className="w-full max-w-xs text-pretty text-sm text-neutral-500">
            {description}
          </p>
        </div>
        <PasswordForm />
      </div>
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:93-119](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L93-L119)

### Edge Metadata and Challenge View Parameters

Metadata generation for password-protected links inspects the workspace plan and domain configuration using `generateMetadata`. When the domain is a Dub domain or the workspace plan is set to `free`, the title appends `- Dub` to the default description string. For custom domains on paid tiers, custom project logos and icons are injected into the metadata response with indexing disabled via `noIndex: true`.

\`\`\`tsx
export async function generateMetadata(props: {
  params: Promise<{ linkId: string }>;
}) {
  const params = await props.params;
  const link = await prismaEdge.link.findUnique({
    where: {
      id: params.linkId,
    },
    select: {
      domain: true,
      project: {
        select: {
          logo: true,
          plan: true,
        },
      },
    },
  });

  if (!link) {
    notFound();
  }

  return constructMetadata({
    title:
      isDubDomain(link.domain) || link.project?.plan === "free"
        ? `${title} - Dub`
        : title,
    description,
    image,
    ...(!isDubDomain(link.domain) &&
      link.project?.plan !== "free" &&
      link.project?.logo && {
        icons: link.project.logo,
      }),
    noIndex: true,
  });
}
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:19-56](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L19-L56)

| Property Name | Target Value / Source | Purpose |
| :--- | :--- | :--- |
| `dynamic` | `force-dynamic` | Disables static caching for password verification gates |
| `runtime` | `edge` | Executes resolution directly on edge workers |
| `image` | `https://assets.dub.co/misc/password-protected.png` | Default Open Graph preview asset |
| `noIndex` | `true` | Prevents search engine indexing on challenge pages |

\`\`\`tsx
export const dynamic = "force-dynamic";
export const runtime = "edge";

const title = "Password Required";
const description =
  "This link is password protected. Enter the password to view it.";
const image = "https://assets.dub.co/misc/password-protected.png";
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:11-17](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L11-L17)

\`\`\`tsx
noIndex: true,
\`\`\`
Sources: [apps/web/app/password/linkId/page.tsx:54-54](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/password/%5BlinkId%5D/page.tsx#L54-L54)

## Edge Exception and Fallback Pages

### Expired, Banned, and Not Found Route Handling

When link resolution fails due to expiration, violation bans, or missing targets, Dub serves dedicated fallback pages and custom redirection rules under `apps/web/app/[domain]/`. For expired links, the page at `apps/web/app/[domain]/expired/page.tsx` checks domain records via Prisma and immediately redirects the client to `domainData.expiredUrl` if a custom fallback is defined. Otherwise, it renders an indefinite cache view (`revalidate = false`) featuring a `CircleHalfDottedClock` icon and marketing parameters (`utm_source: "Expired Link"`, `utm_medium: "Expired Link Page"`).

\`\`\`tsx
export const revalidate = false; // cache indefinitely

export const metadata = constructMetadata({
  title: "Expired Link",
  description:
    "This link has expired. Please contact the owner of this link to get a new one.",
  noIndex: true,
});

const UTM_PARAMS = {
  utm_source: "Expired Link",
  utm_medium: "Expired Link Page",
};

export function generateStaticParams() {
  return [];
}

export default async function ExpiredLinkPage(props: {
  params: Promise<{ domain: string }>;
}) {
  const { domain } = await props.params;
  const domainData = await prisma.domain.findUnique({
    where: {
      slug: domain,
    },
  });

  if (domainData?.expiredUrl) {
    redirect(domainData.expiredUrl);
  }
\`\`\`
Sources: [apps/web/app/domain/expired/page.tsx:12-42](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/expired/page.tsx#L12-L42)

\`\`\`tsx
  return (
    <div>
      <Hero>
        <div className="relative mx-auto flex w-full max-w-md flex-col items-center">
          <BubbleIcon>
            <CircleHalfDottedClock className="size-12" />
          </BubbleIcon>
          <h1
            className={cn(
              "font-display mt-10 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]",
              "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]",
            )}
          >
            Expired link
          </h1>
          <p
            className={cn(
              "mt-5 text-pretty text-base text-neutral-700 sm:text-xl",
              "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]",
            )}
          >
            This link has expired. Please contact the owner of this link to get
            a new one.
          </p>
        </div>

        <div
          className={cn(
            "xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4",
            "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]",
          )}
        >
          <ButtonLink variant="primary" href="https://app.dub.co/register">
            Try Dub today
          </ButtonLink>
          <LearnMoreButton utmParams={UTM_PARAMS} />
        </div>
      </Hero>
      <div className="mt-20">
        <FeaturesSection utmParams={UTM_PARAMS} />
      </div>
      <div className="mt-32">
        <CTA utmParams={UTM_PARAMS} />
      </div>
    </div>
  );
}
\`\`\`
Sources: [apps/web/app/domain/expired/page.tsx:44-90](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/expired/page.tsx#L44-L90)

Similarly, not found routes handled by `apps/web/app/[domain]/notfound/page.tsx` query `prisma.domain.findUnique` for a `notFoundUrl` property. If configured, requests are redirected to the workspace's designated URL; otherwise, a link not found screen with a `GlobeSearch` placeholder and Open Graph metadata (`notfoundlink.jpg`, `noIndex: true`) is displayed.

\`\`\`tsx
export const revalidate = false; // cache indefinitely

export const metadata = constructMetadata({
  title: "Link Not Found",
  description:
    "This link does not exist on Dub. Please check the URL and try again.",
  image: "https://assets.dub.co/misc/notfoundlink.jpg",
  noIndex: true,
});

const UTM_PARAMS = {
  utm_source: "Link Not Found",
  utm_medium: "Link Not Found Page",
};

export function generateStaticParams() {
  return [];
}

export default async function NotFoundLinkPage(props: {
  params: Promise<{ domain: string }>;
}) {
  const { domain } = await props.params;
  const domainData = await prisma.domain.findUnique({
    where: {
      slug: domain,
    },
  });

  if (domainData?.notFoundUrl) {
    redirect(domainData.notFoundUrl);
  }
\`\`\`
Sources: [apps/web/app/domain/notfound/page.tsx:12-43](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L12-L43)

\`\`\`tsx
  return (
    <main className="flex min-h-screen flex-col justify-between">
      <Hero>
        <div className="relative mx-auto flex w-full max-w-md flex-col items-center">
          <BubbleIcon>
            <GlobeSearch className="size-12" />
          </BubbleIcon>
          <h1
            className={cn(
              "font-display mt-10 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]",
              "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]",
            )}
          >
            Link not found
          </h1>
          <p
            className={cn(
              "mt-5 text-pretty text-base text-neutral-700 sm:text-xl",
              "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]",
            )}
          >
            This link does not exist on Dub. Please check the URL and try again.
          </p>
        </div>

        <div
          className={cn(
            "xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4",
            "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]",
          )}
        >
          <ButtonLink variant="primary" href="https://app.dub.co/register">
            Try Dub today
          </ButtonLink>
          <LearnMoreButton utmParams={UTM_PARAMS} />
        </div>
      </Hero>
      <div className="mt-20">
        <FeaturesSection utmParams={UTM_PARAMS} />
      </div>
      <div className="mt-32">
        <CTA utmParams={UTM_PARAMS} />
      </div>
    </main>
  );
}
\`\`\`
Sources: [apps/web/app/domain/notfound/page.tsx:45-90](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L45-L90)

For links violating terms of service, `apps/web/app/[domain]/banned/page.tsx` renders a static `ShieldSlash` icon layout with `Banned Link` metadata without querying database redirection attributes.

\`\`\`tsx
export const revalidate = false; // cache indefinitely

export const metadata = constructMetadata({
  title: "Banned Link",
  description: "This link has been banned for violating our terms of service.",
  noIndex: true,
});

const UTM_PARAMS = {
  utm_source: "Banned Link",
  utm_medium: "Banned Link Page",
};

export function generateStaticParams() {
  return [];
}
\`\`\`
Sources: [apps/web/app/domain/banned/page.tsx:10-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/banned/page.tsx#L10-L25)

\`\`\`tsx
export default function BannedLinkPage() {
  return (
    <div>
      <Hero>
        <div className="relative mx-auto flex w-full max-w-sm flex-col items-center">
          <BubbleIcon>
            <ShieldSlash className="size-12" />
          </BubbleIcon>
          <h1
            className={cn(
              "font-display mt-10 text-center text-4xl font-medium text-neutral-900 sm:text-5xl sm:leading-[1.15]",
              "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:20px] [animation-duration:1s] [animation-fill-mode:both]",
            )}
          >
            Banned link
          </h1>
          <p
            className={cn(
              "mt-5 text-pretty text-base text-neutral-700 sm:text-xl",
              "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]",
            )}
          >
            This link has been banned for violating our terms of service.
          </p>
        </div>

        <div
          className={cn(
            "xs:flex-row relative mx-auto mt-8 flex max-w-fit flex-col items-center gap-4",
            "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:5px] [animation-delay:300ms] [animation-duration:1s] [animation-fill-mode:both]",
          )}
        >
          <ButtonLink variant="primary" href="https://app.dub.co/register">
            Try Dub today
          </ButtonLink>
          <LearnMoreButton utmParams={UTM_PARAMS} />
        </div>
      </Hero>
      <div className="mt-20">
        <FeaturesSection utmParams={UTM_PARAMS} />
      </div>
      <div className="mt-32">
        <CTA utmParams={UTM_PARAMS} />
      </div>
    </div>
  );
\`\`\`
Sources: [apps/web/app/domain/banned/page.tsx:27-72](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/banned/page.tsx#L27-L72)

> [!NOTE]
> All fallback and exception page routes define `generateStaticParams()` returning an empty array to prevent unnecessary static pre-rendering across multi-tenant domains.

\`\`\`tsx
export function generateStaticParams() {
  return [];
}
\`\`\`
Sources: [apps/web/app/domain/expired/page.tsx:26-28](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/expired/page.tsx#L26-L28)

\`\`\`tsx
export function generateStaticParams() {
  return [];
}
\`\`\`
Sources: [apps/web/app/domain/notfound/page.tsx:27-29](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L27-L29)

\`\`\`tsx
export function generateStaticParams() {
  return [];
}
\`\`\`
Sources: [apps/web/app/domain/banned/page.tsx:23-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/banned/page.tsx#L23-L25)

### Link Inspector and Edge Verification Routes

The link inspector located at `apps/web/app/[domain]/[key]/inspect/page.tsx` operates explicitly on the `edge` runtime (`export const runtime = "edge"`). It decodes the URL path key and retrieves link metadata via `getLinkViaEdge({ domain, key })`. If the record does not exist, the handler invokes `notFound()` to abort rendering.

\`\`\`tsx
export const runtime = "edge";
\`\`\`
Sources: [apps/web/app/domain/key/inspect/page.tsx:15-15](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L15-L15)

\`\`\`tsx
export default async function InspectPage(props: {
  params: Promise<{ domain: string; key: string }>;
}) {
  const params = await props.params;
  const domain = params.domain;
  const key = decodeURIComponent(params.key);

  const data = await getLinkViaEdge({ domain, key });

  // if the link doesn't exist
  if (!data) {
    notFound();
  }
\`\`\`
Sources: [apps/web/app/domain/key/inspect/page.tsx:41-53](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L41-L53)

Metadata generation for the inspection view extracts the apex domain of the target URL using `getApexDomain(data.url)` and constructs Google favicon icons combined with unescaped link titles and descriptions.

\`\`\`tsx
export async function generateMetadata(props: {
  params: Promise<{ domain: string; key: string }>;
}) {
  const params = await props.params;
  const domain = params.domain;
  const key = decodeURIComponent(params.key);

  const data = await getLinkViaEdge({ domain, key });

  if (!data) {
    return;
  }

  const apexDomain = getApexDomain(data.url);

  return constructMetadata({
    title: unescape(data.title || ""),
    description: unescape(data.description || ""),
    image: data.image,
    icons: `${GOOGLE_FAVICON_URL}${apexDomain}`,
    noIndex: true,
  });
}
\`\`\`
Sources: [apps/web/app/domain/key/inspect/page.tsx:17-39](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L17-L39)

| Fallback Route File | Target Condition | Action / Render Component |
| :--- | :--- | :--- |
| `expired/page.tsx` | `domainData.expiredUrl` present | Redirects to custom URL; otherwise renders `CircleHalfDottedClock` |
| `notfound/page.tsx` | `domainData.notFoundUrl` present | Redirects to custom URL; otherwise renders `GlobeSearch` |
| `banned/page.tsx` | Terms of service violation | Renders `ShieldSlash` icon and restriction warning |
| `inspect/page.tsx` | Missing link record (`!data`) | Executes `notFound()`; otherwise renders `LinkInspectorCard` |

\`\`\`tsx
if (domainData?.expiredUrl) {
  redirect(domainData.expiredUrl);
}
\`\`\`
Sources: [apps/web/app/domain/expired/page.tsx:40-42](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/expired/page.tsx#L40-L42)

\`\`\`tsx
if (domainData?.notFoundUrl) {
  redirect(domainData.notFoundUrl);
}
\`\`\`
Sources: [apps/web/app/domain/notfound/page.tsx:41-43](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L41-L43)

\`\`\`tsx
<p
  className={cn(
    "mt-5 text-pretty text-base text-neutral-700 sm:text-xl",
    "animate-slide-up-fade motion-reduce:animate-fade-in [--offset:10px] [animation-delay:200ms] [animation-duration:1s] [animation-fill-mode:both]",
  )}
>
  This link has been banned for violating our terms of service.
</p>
\`\`\`
Sources: [apps/web/app/domain/banned/page.tsx:43-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/banned/page.tsx#L43-L50)

\`\`\`tsx
// if the link doesn't exist
if (!data) {
  notFound();
}
\`\`\`
Sources: [apps/web/app/domain/key/inspect/page.tsx:50-53](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L50-L53)

## Related

- [Request Routing](/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/request-routing)
- [A/B Testing](/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/a-b-testing)
- [Tinybird Analytics](/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/tinybird-analytics)

Was this page helpful?