---
title: Next.js adapter
description: Mount the gateway as Next middleware (@rebilder/gateway/next), plus an optional always-markdown route for previews and agent permalinks.
canonical_url: https://rebilder.com/docs/adapters/nextjs
last_updated: "2026-10-28"
---

# [Next.js adapter](https://rebilder.com/docs/adapters/nextjs)

> Mount the gateway as Next middleware (`@rebilder/gateway/next`), plus an optional always-markdown route for previews and agent permalinks.

- **Updated:** 2026-10-28
- **Author:** Rebilder
- **Section:** Adapters
- **Description:** Mount the gateway as Next middleware (@rebilder/gateway/next), plus an optional always-markdown route for previews and agent permalinks.
- **Publisher:** Rebilder

## How it mounts

The adapter imports **nothing** from `next`: Next middleware/proxy handlers and app-router route handlers speak web-standard `Request`/`Response`, so standard types are the whole contract. Next 16 calls the file `proxy.ts`; on Next 15 and earlier the identical code goes in `middleware.ts`.

terminal

```
npm install @rebilder/gateway        # pnpm add / yarn add
```

## The 2-line integration

lib/gateway-config.ts

```
// lib/gateway-config.ts — wire the gateway to your source of truth
import type { GatewayConfig } from '@rebilder/gateway'
import { getProduct, getPolicies, getCollection } from './catalog' // your code
import { getDocument, getDocumentIndex } from './content'          // your code

export const gatewayConfig: GatewayConfig = {
  storeId: 'store_123',
  sources: {
    // Commerce shaped. Wire the ones you have; a site with no catalog wires none.
    product:  (url) => getProduct(url.pathname),      // null when not a PDP
    policies: (url) => getPolicies(url.pathname),
    catalog:  (url) => getCollection(url.pathname),
    // Universal: any other page, and any index of them. Guides, services,
    // locations, articles. Most of a site usually lives here.
    document:   (url) => getDocument(url.pathname),
    collection: (url) => getDocumentIndex(url.pathname),
  },
  onEvent: (event) => { /* queue to your analytics sink; fire-and-forget */ },
}
```

proxy.ts

```
// proxy.ts (Next 16) — middleware.ts on Next ≤15 is identical
import { NextResponse } from 'next/server'
import { createGatewayProxy } from '@rebilder/gateway/next'
import { gatewayConfig } from './lib/gateway-config'

// Pass your fallthrough. The proxy then returns a Response for every request,
// and the HTML half of each negotiated URL gets `Vary: Accept` — see below for
// why that matters more than it looks.
const gateway = createGatewayProxy(gatewayConfig, () => NextResponse.next())

export default gateway

export const config = { matcher: ['/products/:path*', '/policies/:path*', '/collections/:path*'] }
```

A `Response` short-circuits with markdown; `null` continues to your HTML pipeline unchanged: humans, crawlers, protocol routes, URLs no source matched, and sources that threw all pass through. **A thrown source never breaks your site**: errors are contained, the event still fires, the request falls through to HTML.

Already have a proxy or middleware for sessions or auth? Pass it as the fallthrough: `createGatewayProxy(gatewayConfig, (req) => updateSession(req))`. The gateway answers agents first and your code handles everything else.

## Matcher scope

Scope `config.matcher` to the paths your sources can answer (`/products/:path*`, `/policies/:path*`, `/collections/:path*` in the example). Requests outside the matcher never reach the gateway at all, so there is no classification and no event. If you widen sources later, widen the matcher in the same change.

> **One middleware file per app** Next.js allows a single `proxy.ts`/`middleware.ts`. If you already have one, compose inside it (gateway result `??` your existing logic) rather than adding a second file; see [Troubleshooting](/docs/troubleshooting#middleware-matcher-conflicts).

## Cache headers on your HTML

Each negotiated URL now has two representations: markdown from the gateway and HTML from your app. A shared cache has to keep them apart, or it can hand one to the wrong client. The markdown carries `Vary: Accept`, and passing the fallthrough appends it to the response your proxy returns.

What reaches the wire depends on what renders the HTML. Pages Router pages and route handlers keep `Accept` in `Vary`. App Router pages do not: Next sets the page’s `Vary` to its own router tokens after the proxy runs, for static and dynamic pages alike. A `Vary` in `next.config.ts` `headers()` is replaced the same way. This holds on Next 15 and 16, under `next start` and on Vercel.

- On Vercel, the proxy runs before the cache lookup, so an agent is answered with markdown even when the HTML for that URL is cached.
- A cache you put in front of your app (Cloudflare, Fastly, nginx, a corporate proxy) sees App Router HTML without `Accept` in `Vary`. Add `Accept` to that cache’s key, or key on whether `Accept` contains `text/markdown`.
- Check what reaches the wire, not what the code sets: `curl -sSI https://your-site.example/products/x | grep -i vary`.

## Optional: a dedicated markdown route

A stable always-markdown URL, such as an agent permalink or the "what agents see" preview. It renders markdown for **any** requester and returns 404 JSON (`{ "error": "not_found" }`) when no source matches:

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

```
// app/md/[[...path]]/route.ts
import { createGatewayRouteHandler } from '@rebilder/gateway/next'
import { gatewayConfig } from '../../../lib/gateway-config'

export const GET = createGatewayRouteHandler(gatewayConfig, { stripPrefix: '/md' })
```

`stripPrefix` removes the route prefix (whole path segments only) before consulting your sources, so `/md/products/x` resolves against sources keyed by canonical paths (`/products/x`). This route is its own URL, so it doesn’t conflict with the cloaking guardrail, which is about serving different substance on the *same* URL.

## llms.txt route

The same subpath export ships `createLlmsTxtRouteHandler` for serving a deterministic `llms.txt` from your gateway config; usage, options, and adoption notes live on the [llms.txt page](/docs/llms-txt).

## Optional: .md URLs, head link, sitemap.md and 404s

Set `markdownUrls`, `frontmatter` and `notFound` in your config, add `markdownAlternateTypes` to your page metadata, and serve `/sitemap.md` with `createSitemapMdRouteHandler`. Your proxy matcher must include the `.md` paths. For the 404, give `notFound.isMissing` because the proxy runs before your routes and cannot see their status. See [Help agents find your markdown](/docs/discovery).

app/services/[slug]/page.tsx

```
// app/services/[slug]/page.tsx
import type { Metadata } from 'next'
import { markdownAlternateTypes } from '@rebilder/gateway/next'
import { gatewayConfig } from '../../../lib/gateway-config'

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params
  const url = `https://example.com/services/${slug}`
  return {
    alternates: { canonical: url, types: markdownAlternateTypes(gatewayConfig, url) },
  }
}
```