---
title: Next.js
description: Wire @dualmark/core into Next.js 15 App Router manually.
---

There is no dedicated `@dualmark/nextjs` adapter package yet -- but `@dualmark/core` is framework-agnostic and plugs straight into Next.js's App Router via `middleware.ts` and route handlers.

The full working example lives at `examples/nextjs-app-router` and scores **120/125** under `next dev`.

## Architecture

```
┌─────────────────────────────────────────────────────────────┐
│ middleware.ts                                               │
│   - if path ends in .md -> rewrite to /md/<path-no-ext>     │
│   - if AI bot UA OR Accept: text/markdown                   │
│       -> rewrite to /md/<path>                               │
│   - if Accept rules out html+md -> 406                       │
│   - else (HTML) -> next() + Link rel=alternate header        │
└─────────────────────────────────────────────────────────────┘

app/
├── page.tsx                  -> /              (HTML)
├── posts/
│   ├── page.tsx              -> /posts         (HTML)
│   └── [slug]/page.tsx       -> /posts/<slug>  (HTML)
├── md/[...path]/route.ts     -> /md/<*path>    (markdown handler)
└── llms.txt/route.ts         -> /llms.txt
```

## Install

<Tabs items={["bun", "npm", "yarn"]}>
<Tab value="bun">
```bash
bun add @dualmark/core @dualmark/converters
```
</Tab>
<Tab value="npm">
```bash
npm install @dualmark/core @dualmark/converters
```
</Tab>
<Tab value="yarn">
```bash
yarn add @dualmark/core @dualmark/converters
```
</Tab>
</Tabs>

## middleware.ts

```ts title="middleware.ts"
import { detectAIBot, negotiateFormat } from "@dualmark/core";
import { NextResponse, type NextRequest } from "next/server";

export const config = {
  matcher: [
    {
      source: "/((?!_next/|favicon.ico).*)",
      missing: [{ type: "header", key: "next-router-prefetch" }],
    },
  ],
};

function toMarkdownInternalPath(pathname: string): string {
  const clean = pathname.replace(/\.md$/, "").replace(/\/$/, "");
  if (clean === "" || clean === "/") return "/md/index";
  return `/md${clean}`;
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  if (pathname === "/llms.txt") return NextResponse.next();

  if (pathname.endsWith(".md")) {
    const url = req.nextUrl.clone();
    url.pathname = toMarkdownInternalPath(pathname);
    return NextResponse.rewrite(url);
  }

  const ua = req.headers.get("user-agent") ?? "";
  const accept = req.headers.get("accept") ?? "";
  const bot = detectAIBot(ua);
  const fmt = negotiateFormat(accept);

  if (bot.isBot || fmt === "markdown") {
    const url = req.nextUrl.clone();
    url.pathname = toMarkdownInternalPath(pathname);
    return NextResponse.rewrite(url);
  }

  if (fmt === null && accept) {
    return new NextResponse("Not Acceptable\n", {
      status: 406,
      headers: { "Content-Type": "text/plain; charset=utf-8", Vary: "Accept" },
    });
  }

  const res = NextResponse.next();
  const mdPath = pathname.replace(/\/$/, "") + ".md";
  res.headers.set("Link", `<${mdPath}>; rel="alternate"; type="text/markdown"`);
  res.headers.append("Vary", "Accept");
  return res;
}
```

## app/md/[...path]/route.ts

```ts title="app/md/[...path]/route.ts"
import { markdownResponse } from "@dualmark/core";
import { blogConverter } from "@dualmark/converters";

const convert = blogConverter({ siteUrl: "https://example.com", basePath: "/posts" });

export async function GET(_req: Request, ctx: { params: Promise<{ path: string[] }> }) {
  const { path } = await ctx.params;
  const joined = "/" + path.join("/");

  const postMatch = /^\/posts\/([^/]+)$/.exec(joined);
  if (postMatch && postMatch[1]) {
    const post = getPostFromYourSource(postMatch[1]);
    if (!post) return new Response("Not Found", { status: 404 });
    return markdownResponse(convert(post), { cacheControl: "public, max-age=3600" });
  }

  return new Response("Not Found", { status: 404 });
}
```

<Callout>
  **Production note**: `next start` serves prerendered 404s for unknown slugs (like `.md` suffixes) from cache *before* invoking middleware, which can shadow the rewrite. On Vercel and other production-grade hosts, middleware runs at the edge before any cache layer, so this isn't an issue. For local conformance verification, use `next dev`.
</Callout>

## Known limitation: Vercel + Next.js Vary stripping

Next.js's `base-server` overwrites the `Vary` header with its own internal value (`rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding`) on:

- **Prerendered HTML pages** (`/docs/<slug>`) -- costs `html.vary` (recommended, **−5pt**)
- **Prerendered/cached `.md` route handlers** when deployed to Vercel -- costs `md.vary` (required, **−10pt**)

Your middleware-set `Link` header survives. Your `markdownResponse()` body and other AEO headers (`X-Markdown-Tokens`, `X-AEO-Version`, `X-Robots-Tag: noindex`, `X-Content-Type-Options: nosniff`) survive. But `Vary: Accept` does not.

This is a Next.js + Vercel architectural quirk, not a Dualmark bug. The practical impact:

- **Local `next dev`**: 120/125 (only `html.vary` fails -- middleware sets `Vary` directly on route handler responses without the prerender layer in the way)
- **Vercel production**: 110/125 (both `md.vary` and `html.vary` fail because Vercel's edge cache layer strips middleware-set Vary)

Both 110 and 120 are still **Standard conformance (>=80%)**. This is what `dualmark.dev` itself ships with.

**Three options, in order of cost:**

1. **Accept the 10-15pt loss** -- 110/125 is still **Standard (88%)**, well above the 80% threshold. This is what `dualmark.dev` does. No code change. (Recommended.)

2. **Disable prerender on AEO-eligible routes** -- add `export const dynamic = "force-dynamic"` to `app/posts/[slug]/page.tsx` AND your `.md` route handlers. Costs ~50-100ms TTFB per request and disables Vercel's static cache. Gets you 125/125.

3. **Wrap your Next.js output in a Cloudflare Worker** -- deploy via `@dualmark/cloudflare`'s `createAEOWorker`. The worker controls `Vary` end-to-end. Gets you 125/125 + edge bot detection at single-digit-ms TTFB.

For most marketing sites, **option 1 is correct** -- the 15pt loss does not change the conformance level (Standard is achieved at 80%+) and matters far less than the perf and cache-efficiency wins from prerendering.
