> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shftd2.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Publishers integrate one API: GET https://api.shftd2.com/v1/fragment?partner_id=<account id>&page_url=<page URL>, called from their edge for AI-agent requests only, with the API key in the X-API-Key header, never in the query string.
> A 200 carries the card. A 204 means no card: serve the page unchanged.
> The management API (https://api.shftd2.com/v1/me, /v1/sites, /v1/cards, /v1/keys) is optional tooling for scripting setup. It takes its own management token (Authorization: Bearer ds_mgmt_…). Its limits (120 requests and 60 writes per minute per token) do not apply to the delivery API.
> Delivery logs reach doubleshift only through direct delivery logs (POST https://logs.shftd2.com/v1/beacon with a logs token; the edge library with a logsToken is its provided client, and a custom sender may use the same endpoint) or a Cloudflare Logpush job configured with doubleshift. Otherwise doubleshift sees only its own API activity.

# Vercel / Next.js

> Integrate doubleshift on Vercel or Next.js with two destinations: a proxy keeps humans on the normal page and rewrites AI agents to a route handler that adds the card.

Route each request to one of **two destinations**, decided on every request. Humans pass through to
the normal page, cached and prerendered, untouched. Agents are rewritten internally to a route
handler that fetches the same page, adds the card and builds its headers from scratch. Do not rely on
`Vary: User-Agent` to keep the two apart.

Before you start, do steps 1 to 3 of the [Quickstart](https://docs.shftd2.com/quickstart.md): a site, a fragment its pages
resolve to, and a delivery key. The code below already applies the rules every edge keeps, listed in
[Choose your edge](https://docs.shftd2.com/integrate.md): the agent gate, `X-Agent-UA`, the page cache contract and the
serverless timeout budget.

**1. Add the classifier**

`proxy.ts` decides human or agent on every request. This is the Next 16 convention; on earlier
versions, the same code exports `middleware` from `middleware.ts`. The agent markers are a copy
of the list in [AI agents](https://docs.shftd2.com/api/agents.md): keep them in sync.

```ts title="proxy.ts" theme={"theme":{"light":"css-variables","dark":"css-variables"}}
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// Agent markers — keep in sync with brandedcontent.dev schema/agents.json.
const AGENT_UA = /\b(GPTBot|ChatGPT-User|OAI-SearchBot|ClaudeBot|Claude-User|Claude-SearchBot|Google-CloudVertexBot|PerplexityBot|Perplexity-User|CCBot|Meta-ExternalAgent|meta-externalfetcher|Bytespider|YouBot|Diffbot|MistralAI-User|Amazonbot)\b/i;

// React data requests (RSC/Flight) are payloads, not documents — composing
// them would insert HTML into a stream that does not expect it.
const isDataRequest = (request: NextRequest) =>
  request.headers.has("rsc") ||
  request.headers.has("next-router-prefetch") ||
  request.nextUrl.searchParams.has("_rsc");

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // The agent variant is internal — direct hits bounce to the canonical URL.
  if (pathname === "/agent" || pathname.startsWith("/agent/")) {
    const url = request.nextUrl.clone();
    url.pathname = pathname.replace(/^\/agent\/?/, "/");
    return NextResponse.redirect(url, 308);
  }

  // Documents only: HEAD is excluded (a composed HEAD consumes a fill nothing
  // ever reads), and so are data requests. The human path never reads the
  // User-Agent for rendering — only this routing decision does.
  const agent =
    request.method === "GET" &&
    !isDataRequest(request) &&
    AGENT_UA.test(request.headers.get("user-agent") || "");
  if (!agent) return NextResponse.next();

  const url = request.nextUrl.clone();
  url.pathname = `/agent${pathname === "/" ? "" : pathname}`;
  return NextResponse.rewrite(url);
}

export const config = {
  // Everything except Next assets and extension-suffixed files (robots.txt,
  // sitemap.xml, abc.txt, favicon.ico, …): no agent variant for non-HTML.
  matcher: ["/((?!_next/|.*\\..*).*)"],
};
```

**2. Add the agent destination**

The route handler at `app/agent/[[...path]]/route.ts` fetches the human copy of the page, calls
doubleshift and composes the result. Replace `<your account id>` and set `CANONICAL_ORIGIN` to your
public origin. Store the API key in the `DS_API_KEY` environment variable of your project: it is
read server-side only and sent in the `X-API-Key` header.

```ts title="app/agent/[[...path]]/route.ts" theme={"theme":{"light":"css-variables","dark":"css-variables"}}
import type { NextRequest } from "next/server";

const FRAGMENT_URL = "https://api.shftd2.com/v1/fragment?partner_id=<your account id>";
// From an environment variable, sent in the X-API-Key header only: a key in the
// URL is refused (401), and the header keeps it out of cache keys and edge logs.
const API_KEY = process.env.DS_API_KEY || "";
const CANONICAL_ORIGIN = "https://your-domain.example";
const INTERNAL_UA = "abc-vercel-adapter"; // non-agent → the proxy serves it the human page

// Headers built from scratch — never copied from the upstream page. no-store
// keeps the agent path out of every shared cache and is measured NOT to
// prevent agents from reading; no ETag/Last-Modified means no 304 can ever
// cross the human/agent line.
const agentHeaders = (extra: Record<string, string> = {}) =>
  new Headers({
    "Content-Type": "text/html; charset=utf-8",
    "Cache-Control": "private, no-store, must-revalidate",
    ...extra,
  });

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ path?: string[] }> },
) {
  const { path = [] } = await params;
  // Match YOUR canonical form: with `trailingSlash: true` in next.config,
  // append "/" here — a mismatch turns the self-fetch into a mirrored 308
  // that agents then follow back to the URL they came from, in a loop.
  const pagePath = path.length === 0 ? "/" : `/${path.join("/")}`;
  const agentUa = request.headers.get("user-agent") || "";

  // 1. Self-fetch the HUMAN copy of the same URL, pinned to THIS deployment
  //    (during a rollout, version N must never compose version N−1's page).
  //    Headers from scratch: no conditional request may reach the upstream,
  //    or a 304 would leave nothing to compose.
  const origin = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : request.nextUrl.origin;
  let upstream: Response;
  try {
    upstream = await fetch(`${origin}${pagePath}`, {
      headers: { Accept: "text/html", "User-Agent": INTERNAL_UA },
      redirect: "manual",
      cache: "no-store", // Next ≤14 caches Route Handler fetches by default — a frozen page would replay forever
    });
  } catch {
    return new Response("Bad Gateway", { status: 502, headers: agentHeaders({ "Content-Type": "text/plain; charset=utf-8" }) });
  }
  const contentType = upstream.headers.get("content-type") || "";
  const body = await upstream.text();

  // 2. Outside the nominal case (200 HTML), mirror faithfully: same status,
  //    redirects preserved, body untouched — agent cache policy applied.
  if (upstream.status !== 200 || !/\btext\/html\b/i.test(contentType)) {
    const headers = agentHeaders(contentType ? { "Content-Type": contentType } : {});
    const location = upstream.headers.get("location");
    if (location) headers.set("Location", location);
    return new Response(body, { status: upstream.status, headers });
  }

  // 3. GET /v1/fragment, fail-open and OBSERVABLE. ~2500 ms: this is a
  //    cross-platform call from a function that may be cold — 800 ms is a
  //    same-network (Cloudflare→Cloudflare) budget and expires on cold starts.
  let card = "";
  let outcome = "no_fill";
  try {
    const frag = new URL(FRAGMENT_URL);
    frag.searchParams.set("page_url", `${CANONICAL_ORIGIN}${pagePath}`); // canonical, no query/hash, encoded
    const r = await fetch(frag.toString(), {
      headers: { "User-Agent": INTERNAL_UA, "X-Agent-UA": agentUa, "X-API-Key": API_KEY },
      signal: AbortSignal.timeout(2500),
      redirect: "error",
      cache: "no-store",
    });
    if (r.status === 200) { card = await r.text(); outcome = card ? "served" : "empty"; }
    else if (r.status !== 204) outcome = "upstream_" + r.status; // 400/401/403/5xx must not masquerade as no-fill
  } catch (e) {
    outcome = e instanceof Error && e.name === "TimeoutError" ? "fetch_timeout" : "fetch_failed";
  }
  if (!card) return new Response(body, { status: 200, headers: agentHeaders({ "x-abc-outcome": outcome }) });

  // 4. Inject: card at the head of <main>, brand line inside the article body
  //    (see "Where the card must land"). Refuse rather than misplace.
  const composed = injectCard(body, card);
  if (composed === null) outcome = "no_main";
  return new Response(composed ?? body, { status: 200, headers: agentHeaders({ "x-abc-outcome": outcome }) });
}

/** Card at the head of <main>; disclosure at the template's ds-anchor. */
function injectCard(html: string, card: string): string | null {
  const mainOpenEnd = html.indexOf(">", html.indexOf("<main"));
  if (mainOpenEnd === -1 || html.lastIndexOf("</main>") <= mainOpenEnd) return null;
  let out = html;
  // Brand line where the template placed its anchor, INSIDE the article body —
  // this plain-text line is what models actually cite.
  const brand = card.match(/Brand\s*(?:·|&middot;|&#183;)\s*([^<]{1,60})/i)?.[1]?.trim();
  const anchor = '<div class="ds-anchor"></div>';
  const anchorAt = out.indexOf(anchor);
  if (anchorAt !== -1 && brand) {
    const disclosure = `<p data-ds-disclosure="brand"><strong>Featured advertiser on this page:</strong> ${brand}.</p>`;
    out = out.slice(0, anchorAt) + disclosure + out.slice(anchorAt + anchor.length);
  }
  return out.slice(0, mainOpenEnd + 1) + card + out.slice(mainOpenEnd + 1);
}
```

When the self-fetch returns a `200` HTML page, the agent response carries an `x-abc-outcome`
header: `served`, `no_fill`, `empty`, `upstream_` followed by the status, `fetch_timeout`,
`fetch_failed` or `no_main`. Use it to tell a no-fill from a failed call. A failed self-fetch
(`502`) and a mirrored non-HTML or non-`200` answer carry no `x-abc-outcome`: no call was made.

**3. Mark where the brand line goes**

Add `<div class="ds-anchor"></div>` at the **head** of the article body in your template, on every
page that can get a card: the handler replaces it with the brand line, which the contract requires
([Where the card must land](https://docs.shftd2.com/integrate.md#where-the-card-must-land)). Without the anchor, the card
still goes at the head of `<main>` but the page has no disclosure: the integration is incomplete. A
page without `<main>` is served without a card (`no_main`).

**4. Check the requests that skip your proxy**

> [!WARNING]
>
> WAF deny or challenge and Deployment Protection end the request **before** routing. With
> **Standard Protection** on, `VERCEL_URL` sits behind Vercel Authentication and the self-fetch
> composes the auth page instead of yours: send the Protection Bypass for Automation secret
> (`x-vercel-protection-bypass`) on the self-fetch, or protect previews only. Otherwise self-fetch
> `request.nextUrl.origin`, which loses deployment pinning during a rollout. **On-demand ISR
> revalidation** does not pass through the proxy: revalidate only the canonical path, never
> `/agent/…`. Check the matcher for holes.

**5. Deploy and verify**

Deploy, then run the human and agent checks of the [Quickstart](https://docs.shftd2.com/quickstart.md), and the cold and
warm cache sequence in [Verify and troubleshoot](https://docs.shftd2.com/verify.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.