docs
    site-kit: Proxy
    v7.2.0.md

    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 next16

    Runs 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 runtime export. Proxy defaults to the Node.js runtime and the runtime config option is unavailable in proxy files — setting it throws at build time. (middleware.ts still accepts runtime: '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)

    1. Before hook — optional custom logic (return NextResponse to short-circuit)
    2. Sonor-managed redirects — 301/302/307/308 with query param preservation
    3. Security headers — see the table below
    4. 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. See wantsLlmsDiscoveryLink in @sonordev/site-kit/llms.
    5. After hook — mutate response headers

    Security Headers (defaults)

    HeaderValue
    X-DNS-Prefetch-Controlon
    Content-Security-Policyframe-ancestors from DEFAULT_FRAME_ANCESTORS (below)
    X-Content-Type-Optionsnosniff
    X-XSS-Protection1; mode=block
    Referrer-Policystrict-origin-when-cross-origin
    Permissions-Policycamera=(), 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.