feat(docs): return page suggestions in markdown 404 pages (#45439)

Markdown guides (`/docs/guides/**/*.md`) that 404 currently return a
text/plain `Not found` response. Since agents often hallucinate URL
paths, this PR proactively provides page suggestions so that agents can
find the page they are looking forward without further guessing. It uses
the docs Content API to fetch related pages, similar to the [HTML 404
page](https://supabase.com/docs/404):

```
# 404 - Page Not Found

The page `/docs/guides/mcp.md` does not exist.

## You might be looking for...

- [Model context protocol (MCP)](https://supabase.com/docs/guides/getting-started/mcp.md)
- [Building an MCP Server with mcp-lite](https://supabase.com/docs/guides/functions/examples/mcp-server-mcp-lite.md)
- [Model Context Protocol (MCP) Authentication](https://supabase.com/docs/guides/auth/oauth-server/mcp-authentication.md)
- [Deploy MCP servers](https://supabase.com/docs/guides/getting-started/byo-mcp.md)
- [Enabling MCP Server Access](https://supabase.com/docs/guides/self-hosting/enable-mcp.md)

See also: [Changelog](https://supabase.com/changelog.md)
```

## How to test
1. Use curl to fetch a non-existent page with an `.md` extension:

   ```shell
curl -i
https://docs-git-docs-markdown-404-suggestions-supabase.vercel.app/docs/guides/mcp.md
   ```

Confirm that relevant pages are suggested (ballpark - our search algo
needs some improvement). Also confirm that the response has content type
`text/markdown`.

2. Use curl to fetch a non-existent page using the `Accept:
text/markdown` header


   ```shell
   curl -i -H 'Accept: text/markdown' \

https://docs-git-docs-markdown-404-suggestions-supabase.vercel.app/docs/guides/mcp
   ```

    And confirm the same result as 1.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* 404 pages for missing guides now return a formatted Markdown response
with proper content-type and no-cache headers, and include up to five
related documentation suggestions to help users find relevant content.

* **Chores**
* Build environment now preserves an additional hosting URL variable to
improve build/task consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Greg Richardson
2026-05-05 00:40:44 -06:00
committed by GitHub
parent b554f58106
commit b539cc0529
2 changed files with 87 additions and 5 deletions

View File

@@ -1,14 +1,15 @@
import { promises as fs } from 'fs'
import path from 'path'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { BASE_PATH, IS_PRODUCTION, PROD_URL } from '~/lib/constants'
import { NextResponse } from 'next/server'
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
export async function GET(request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params
const baseDir = path.join(process.cwd(), 'public/docs/guides')
const filePath = path.join(baseDir, `${slug.join('/')}.md`)
if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
return new NextResponse('Not found', { status: 404 })
return notFoundResponse(request, slug)
}
try {
@@ -20,6 +21,86 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
},
})
} catch {
return new NextResponse('Not found', { status: 404 })
return notFoundResponse(request, slug)
}
}
/**
* Resolves the docs base URL from trusted sources only.
* Avoids host-header-driven SSRF from request origin.
*/
function resolveDocsBaseUrl() {
if (IS_PRODUCTION) return PROD_URL
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}${BASE_PATH}`
if (process.env.NEXT_PUBLIC_SITE_URL) return `${process.env.NEXT_PUBLIC_SITE_URL}${BASE_PATH}`
return undefined
}
async function notFoundResponse(_request: Request, slug: string[]) {
const baseUrl = resolveDocsBaseUrl()
const query = slug.join(' ').replace(/[_-]/g, ' ')
const suggestions = baseUrl ? await searchForSuggestions(baseUrl, query) : []
const markdown = buildNotFoundMarkdown(slug, suggestions)
return new NextResponse(markdown, {
status: 404,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'no-store',
},
})
}
interface SearchSuggestion {
title: string
href: string
}
async function searchForSuggestions(baseUrl: string, query: string): Promise<SearchSuggestion[]> {
try {
const response = await fetch(`${baseUrl}/api/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query SearchDocs($query: String!) {
searchDocs(query: $query, limit: 5) {
nodes {
title
href
}
}
}
`,
variables: { query },
}),
})
if (!response.ok) return []
const data = await response.json()
return (data?.data?.searchDocs?.nodes ?? []).filter(
(n: Partial<SearchSuggestion>): n is SearchSuggestion => !!n.title && !!n.href
)
} catch {
return []
}
}
function buildNotFoundMarkdown(slug: string[], suggestions: SearchSuggestion[]): string {
const pagePath = slug.join('/')
const suggestionsMd =
suggestions.length > 0
? `## You might be looking for...\n\n${suggestions
.map(({ title, href }) => `- [${title}](${href}.md)`)
.join('\n')}\n`
: ''
return `# 404 - Page Not Found
The page \`/docs/guides/${pagePath}.md\` does not exist.
${suggestionsMd}
See also: [Changelog](https://supabase.com/changelog.md)
`
}

View File

@@ -30,6 +30,7 @@
"VERCEL",
"VERCEL_ENV",
"VERCEL_GIT_COMMIT_SHA",
"VERCEL_URL",
// These envs are used in the packages
"NEXT_PUBLIC_STORAGE_KEY",
"NEXT_PUBLIC_AUTH_DEBUG_KEY",