Proxy — @sonordev/site-kit/proxy
Composable Next.js Proxy factory. Zero-config redirects + security headers. Opt-in AI discovery headers.
Next 16 renamed the middleware file convention to proxy. Through 7.x this
module is also served on @sonordev/site-kit/middleware, with createMiddleware
as an alias of createProxy; both go in 8.0. npx sonor-setup codemod --write
moves a site over (the file too).
Usage
// proxy.ts — at the project root, or in src/
import { createProxy } from '@sonordev/site-kit/proxy'
export default createProxy()
// Inlined on purpose. See "The matcher must be inlined" below — importing
// siteKitMatcher here is a build error.
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:ico|png|jpg|jpeg|gif|webp|svg|woff2?)$).*)',
],
}With options:
export default createProxy({
securityHeaders: { frameAncestors: ["'self'", 'https://partner.example.com'] },
llmsDiscovery: { siteUrl: 'https://example.com' },
before: (req) => {
// Custom auth check, geolocation, etc.
},
})The matcher must be inlined
export const config = siteKitMatcher is a build error. Next statically
parses config.matcher at build time and Turbopack rejects any imported value,
so the array has to be a literal in your own file. Copy it from the example
above, or read it off siteKitMatcher — but paste the contents, don't export
the binding.
siteKitMatcher exists so the canonical pattern lives in one place that the
scaffold, the docs, and the tests all read. It is a reference value.
Migrating from middleware.ts
npx sonor-setup next16Runs the codemod's next-16-proxy transform (deterministic and offline: it
moves the file, renames a named middleware export to proxy, and never
moves onto an existing proxy.ts) and re-runs the doctor check afterwards, so
the result is verified rather than assumed. Safe to run twice. A file that
sets runtime is flagged rather than moved (see below).
Two Next 16 traps to know:
- No
runtimeexport. Proxy defaults to the Node.js runtime and theruntimeconfig option is unavailable in proxy files — setting it throws at build time. (middleware.tsstill acceptsruntime: 'nodejs', and it does run on Netlify; remove the export before moving the file.) - The matcher, as above.
Config
interface SiteKitProxyConfig {
redirects?: boolean | RedirectConfig // Sonor-managed redirects (default: true)
securityHeaders?: boolean | SecurityHeadersConfig // Security headers (default: true)
llmsDiscovery?: LlmsDiscoveryConfig | false // AI crawler discovery header
identity?: boolean | IdentityConfig // Opt-in edge identity pass
before?: (req: NextRequest) => NextResponse | undefined | Promise<NextResponse | undefined>
after?: (req: NextRequest, res: NextResponse) => NextResponse | Promise<NextResponse>
}
interface LlmsDiscoveryConfig {
siteUrl: string // e.g. 'https://example.com'
llmsPath?: string // Default: '/llms.txt'
mcpServerCard?: boolean | string // Also link the MCP server card (rel="service-desc"); true = '/.well-known/mcp-server-card'
}redirects: true (the default) checks managed rules before each page render.
The rule list is cached in memory for five minutes per instance, and a fetch is
bounded at 300ms with a 30s cooldown after a failure, so page loads rarely wait
on it. Set redirects: false only on a site that uses no Sonor-managed
redirects.
Don't resolve redirects in app/not-found.tsx with resolveManagedRedirect().
Next renders the root not-found boundary inside every page, so its headers()
call makes every route dynamic: on a Next 16 build every static route turned
into a dynamic one. The resolver is deprecated.
What It Handles (in order)
- Before hook — optional custom logic (return
NextResponseto short-circuit) - Sonor-managed redirects — 301/302/307/308 with query param preservation
- Security headers — see the table below
- AI discovery header —
Link: <url>; rel="describedby"; type="text/markdown"on every request that could be for a page: GET or HEAD, an Accept header that allows HTML (or no Accept header at all, which is how curl and many crawlers ask), not a file-like path (/llms.txt,/sitemap.xml), not/api/, and not a Next RSC navigation request. SeewantsLlmsDiscoveryLinkin@sonordev/site-kit/llms. - After hook — mutate response headers
Security Headers (defaults)
| Header | Value |
|---|---|
| X-DNS-Prefetch-Control | on |
| Content-Security-Policy | frame-ancestors from DEFAULT_FRAME_ANCESTORS (below) |
| X-Content-Type-Options | nosniff |
| X-XSS-Protection | 1; mode=block |
| Referrer-Policy | strict-origin-when-cross-origin |
| Permissions-Policy | camera=(), microphone=(), geolocation=() |
Note there is no X-Frame-Options row — that is deliberate, see below.
Framing / frame-ancestors
Managed sites ship a CSP frame-ancestors allowlist instead of
X-Frame-Options. The default, DEFAULT_FRAME_ANCESTORS, is 'self',
Sonor's dashboard (https://app.sonor.io, for Edit on page) and the Upforge
showcase origins (upforge.io, upforgelabs.com and their subdomains), where
live client sites appear in portfolio frames. XFO has no allowlist form
(ALLOW-FROM is dead), and emitting both would let a stray DENY silently
re-block the embed, so X-Frame-Options is omitted whenever
frameAncestors is active. Every origin not on the list is still blocked.
frameAncestors replaces the default, so spread it to add an origin:
import { createProxy, DEFAULT_FRAME_ANCESTORS } from '@sonordev/site-kit/proxy'
createProxy({
securityHeaders: {
frameAncestors: [...DEFAULT_FRAME_ANCESTORS, 'https://partner.example.com'],
},
})
// opt out entirely → falls back to X-Frame-Options: DENY
createProxy({ securityHeaders: { frameAncestors: false } })DEFAULT_FRAME_ANCESTORS is the single source of truth — add origins there
rather than re-forking the list into individual sites.