Next.js adapter
Mount the gateway as Next middleware (@rebilder/gateway/next), plus an optional always-markdown route for previews and agent permalinks.
Last updated
On this page
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.
npm install @rebilder/gateway # pnpm add / yarn addThe 2-line integration#
// 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 on 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`. Shared caches
// need that header to keep the markdown and the HTML apart.
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.
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
AcceptinVary. AddAcceptto that cache’s key, or key on whetherAcceptcontainstext/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
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.
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.
// 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) },
}
}