---
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-09-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-09-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. Both must carry `Vary: Accept`, or a shared cache can hand one to the wrong client. The gateway sets it on the markdown, and passing the fallthrough adds it to the response your proxy returns.

Next.js adds its own `Vary` tokens to the final page response, and a `Vary` set in middleware does not reach the wire. Declare it in `next.config.ts` for the paths you serve markdown on, repeating the router tokens so client-side navigation keeps working:

next.config.ts

```
// next.config.ts (inside the config object)
async headers() {
  return ['/', '/products/:path*', '/policies/:path*'].map((source) => ({
    source,
    headers: [
      {
        key: 'Vary',
        value:
          'rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept',
      },
    ],
  }))
}
```

- 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 origin (Cloudflare, Fastly, a corporate proxy) sits outside your proxy. There the `next.config.ts` header does the work. If that cache ignores `Vary`, add `Accept` to its cache key.
- 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) },
  }
}
```