From b539cc0529833553a04fdadda27eef477e99bd48 Mon Sep 17 00:00:00 2001 From: Greg Richardson Date: Tue, 5 May 2026 00:40:44 -0600 Subject: [PATCH] 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. ## 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. --- .../docs/app/api/guides-md/[...slug]/route.ts | 91 ++++++++++++++++++- apps/docs/turbo.jsonc | 1 + 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts index b942ff2f73c..3e877b3181f 100644 --- a/apps/docs/app/api/guides-md/[...slug]/route.ts +++ b/apps/docs/app/api/guides-md/[...slug]/route.ts @@ -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 { + 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): 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) +` +} diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index 3c1148eb299..b025460e4ac 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -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",