diff --git a/apps/www/eslint-rules/no-cross-zone-link.cjs b/apps/www/eslint-rules/no-cross-zone-link.cjs
new file mode 100644
index 00000000000..857777b977f
--- /dev/null
+++ b/apps/www/eslint-rules/no-cross-zone-link.cjs
@@ -0,0 +1,74 @@
+const { readFileSync } = require('node:fs')
+const { join } = require('node:path')
+const vm = require('node:vm')
+
+const SUPABASE_ORIGIN = 'https://supabase.com'
+
+// Evaluated as production: the /docs rewrite only exists there, and /docs links must be flagged in every environment.
+function loadProductionRewrites() {
+ const rewritesModule = { exports: [] }
+ const source = readFileSync(join(__dirname, '../lib/rewrites.js'), 'utf8')
+ vm.runInNewContext(source, {
+ module: rewritesModule,
+ process: { env: { NEXT_PUBLIC_VERCEL_ENV: 'production' } },
+ })
+ return rewritesModule.exports
+}
+
+const crossZonePrefixes = loadProductionRewrites()
+ .filter(({ destination }) => !destination.startsWith('/'))
+ .map(({ source }) => source.replace(/\/(:path\*)?$/, ''))
+
+function isCrossZonePath(href) {
+ const path = href.startsWith(SUPABASE_ORIGIN) ? href.slice(SUPABASE_ORIGIN.length) : href
+ const pathname = path.split(/[?#]/)[0]
+ return crossZonePrefixes.some(
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
+ )
+}
+
+function staticHrefPrefix(value) {
+ if (!value) return null
+ if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null
+ if (value.type !== 'JSXExpressionContainer') return null
+
+ const { expression } = value
+ if (expression.type === 'Literal' && typeof expression.value === 'string') return expression.value
+ if (expression.type === 'TemplateLiteral') return expression.quasis[0].value.cooked
+ return null
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ messages: {
+ crossZone:
+ "'{{href}}' is served by another zone. Use a plain : Next.js can't prefetch or client-navigate across zones, so 404s its prefetch.",
+ },
+ schema: [],
+ },
+ create(context) {
+ const linkNames = new Set()
+
+ return {
+ ImportDeclaration(node) {
+ if (node.source.value !== 'next/link') return
+ for (const specifier of node.specifiers) {
+ if (specifier.type === 'ImportDefaultSpecifier') linkNames.add(specifier.local.name)
+ }
+ },
+ JSXOpeningElement(node) {
+ if (node.name.type !== 'JSXIdentifier' || !linkNames.has(node.name.name)) return
+
+ const hrefAttribute = node.attributes.find(
+ (attribute) => attribute.type === 'JSXAttribute' && attribute.name.name === 'href'
+ )
+ const href = staticHrefPrefix(hrefAttribute?.value)
+ if (!href || !isCrossZonePath(href)) return
+
+ context.report({ node: hrefAttribute, messageId: 'crossZone', data: { href } })
+ },
+ }
+ },
+}
diff --git a/apps/www/eslint-rules/no-cross-zone-link.test.ts b/apps/www/eslint-rules/no-cross-zone-link.test.ts
new file mode 100644
index 00000000000..7b6e70c82cb
--- /dev/null
+++ b/apps/www/eslint-rules/no-cross-zone-link.test.ts
@@ -0,0 +1,39 @@
+import { RuleTester } from 'eslint'
+import { describe, it } from 'vitest'
+
+import noCrossZoneLink from './no-cross-zone-link.cjs'
+
+RuleTester.describe = describe
+RuleTester.it = it
+
+const ruleTester = new RuleTester({
+ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },
+})
+
+ruleTester.run('no-cross-zone-link', noCrossZoneLink, {
+ valid: [
+ 'import Link from \'next/link\'; ',
+ 'import Link from \'next/link\'; ',
+ "import Link from 'next/link'; ",
+ '',
+ 'import Link from \'somewhere-else\'; ',
+ ],
+ invalid: [
+ {
+ code: 'import Link from \'next/link\'; ',
+ errors: [{ messageId: 'crossZone' }],
+ },
+ {
+ code: 'import Link from \'next/link\'; ',
+ errors: [{ messageId: 'crossZone' }],
+ },
+ {
+ code: "import Link from 'next/link'; ",
+ errors: [{ messageId: 'crossZone' }],
+ },
+ {
+ code: 'import NextLink from \'next/link\'; ',
+ errors: [{ messageId: 'crossZone' }],
+ },
+ ],
+})
diff --git a/apps/www/eslint.config.cjs b/apps/www/eslint.config.cjs
index 4f6ba19eb29..37ec0adc370 100644
--- a/apps/www/eslint.config.cjs
+++ b/apps/www/eslint.config.cjs
@@ -1,6 +1,8 @@
const { defineConfig } = require('eslint/config')
const supabaseConfig = require('eslint-config-supabase/next')
+const noCrossZoneLink = require('./eslint-rules/no-cross-zone-link.cjs')
+
module.exports = defineConfig([
supabaseConfig,
{
@@ -19,4 +21,9 @@ module.exports = defineConfig([
'react/no-children-prop': 'warn',
},
},
+ {
+ files: ['**/*.{jsx,tsx}'],
+ plugins: { www: { rules: { 'no-cross-zone-link': noCrossZoneLink } } },
+ rules: { 'www/no-cross-zone-link': 'error' },
+ },
])