diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore
index 55ab4a6d45e..19731c8f0d6 100644
--- a/apps/docs/.gitignore
+++ b/apps/docs/.gitignore
@@ -30,6 +30,8 @@ public/sitemap.xml
# llms.txt (generated)
public/llms.txt
public/llms/
+# Generated guide markdown files
+public/docs/
# Copied examples folder
/examples/
diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md
index 2d9f7cb3684..4413ab84577 100644
--- a/apps/docs/DEVELOPERS.md
+++ b/apps/docs/DEVELOPERS.md
@@ -22,6 +22,19 @@ For a complete run-down on how all of our tools work together, see the main DEVE
4. Visit http://localhost:3001/docs in your browser - don't forget to append the `/docs` to the end
5. Your local site should look exactly like [https://supabase.com/docs](https://supabase.com/docs)
+## AI friendly documentation
+
+This project generates Markdown files for each page under `/docs/guides/..` path.
+
+To test locally, within the `apps/docs` directory:
+
+1. Run `pnpm build:guides-markdown`
+2. Run `pnpm dev`
+
+This creates Markdown files for all routes under the `public/docs/guides` directory, ignored by Git.
+
+For production this setup runs as a `prebuild` task to allow Vercel to bundle these files with middleware and functions.
+
## Contributing
For repo organization and style guide, see the [contributing guide](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md).
diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts
new file mode 100644
index 00000000000..f09a7854f25
--- /dev/null
+++ b/apps/docs/app/api/guides-md/[...slug]/route.ts
@@ -0,0 +1,22 @@
+import { promises as fs } from 'fs'
+import path from 'path'
+import { NextResponse } from 'next/server'
+
+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 })
+ }
+
+ try {
+ const content = await fs.readFile(filePath, 'utf-8')
+ return new NextResponse(content, {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ })
+ } catch {
+ return new NextResponse('Not found', { status: 404 })
+ }
+}
diff --git a/apps/docs/app/guides/local-development/cli/config/page.tsx b/apps/docs/app/guides/local-development/cli/config/page.tsx
index 9ce99b1da78..341d800b6f8 100644
--- a/apps/docs/app/guides/local-development/cli/config/page.tsx
+++ b/apps/docs/app/guides/local-development/cli/config/page.tsx
@@ -1,7 +1,7 @@
import ReactMarkdown from 'react-markdown'
import { CodeBlock } from 'ui'
import { Heading } from 'ui/src/components/CustomHTMLElements'
-import { type TOCHeader } from '~/components/GuidesTableOfContents'
+import { type TOCHeader } from '~/components/GuidesSidebar'
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import type { Parameter } from '~/lib/refGenerator/refTypes'
diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx
new file mode 100644
index 00000000000..ee4e8c49b4b
--- /dev/null
+++ b/apps/docs/components/GuidesSidebar.tsx
@@ -0,0 +1,134 @@
+'use client'
+
+import { Check, Copy, ExternalLink } from 'lucide-react'
+import { usePathname } from 'next/navigation'
+import { useState } from 'react'
+import { isFeatureEnabled } from 'common'
+import { cn } from 'ui'
+import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
+import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc'
+import { Feedback } from '~/components/Feedback'
+import { useTocAnchors } from '../features/docs/GuidesMdx.state'
+
+interface TOCHeader {
+ id?: string
+ text: string
+ link: string
+ level: number
+}
+
+function AiTools({ className }: { className?: string }) {
+ const [copied, setCopied] = useState(false)
+ let url = ''
+
+ // Safe check for server side rendering.
+ try {
+ const urlParts = new URL(`${window.location}`)
+ url = urlParts.origin + urlParts.pathname
+ } catch (error) {}
+
+ async function copyMarkdown() {
+ const mdUrl = `${url}.md`
+
+ try {
+ const res = await fetch(mdUrl)
+ const text = await res.text()
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch (error) {
+ console.error('Failed to copy markdown', error)
+ }
+ }
+
+ return (
+
+ )
+}
+
+const GuidesSidebar = ({
+ className,
+ video,
+ hideToc,
+}: {
+ className?: string
+ video?: string
+ hideToc?: boolean
+}) => {
+ const pathname = usePathname()
+ const { toc } = useTocAnchors()
+ const showFeedback = isFeatureEnabled('feedback:docs')
+ const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg`
+
+ return (
+
+
+ {video && (
+
+
+
+ )}
+ {showFeedback && (
+
+
+
+ )}
+
+ {!hideToc && toc.length !== 0 && (
+
+
+ On this page
+
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default GuidesSidebar
+export { GuidesSidebar }
+export type { TOCHeader }
diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx
deleted file mode 100644
index ebb1fd4158f..00000000000
--- a/apps/docs/components/GuidesTableOfContents.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-'use client'
-
-import { usePathname } from 'next/navigation'
-import { isFeatureEnabled } from 'common'
-import { cn } from 'ui'
-import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
-import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc'
-import { Feedback } from '~/components/Feedback'
-import { useTocAnchors } from '../features/docs/GuidesMdx.state'
-
-interface TOCHeader {
- id?: string
- text: string
- link: string
- level: number
-}
-
-const GuidesTableOfContents = ({ className, video }: { className?: string; video?: string }) => {
- const pathname = usePathname()
- const { toc } = useTocAnchors()
-
- const showFeedback = isFeatureEnabled('feedback:docs')
-
- const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg`
-
- return (
-
-
- {video && (
-
-
-
- )}
- {showFeedback && (
-
-
-
- )}
- {toc.length !== 0 && (
-
-
- On this page
-
-
-
-
-
- )}
-
-
- )
-}
-
-export default GuidesTableOfContents
-export type { TOCHeader }
diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx
index 76fbe6d397c..97758032965 100644
--- a/apps/docs/features/docs/GuidesMdx.template.tsx
+++ b/apps/docs/features/docs/GuidesMdx.template.tsx
@@ -5,7 +5,7 @@ import ReactMarkdown from 'react-markdown'
import { cn } from 'ui'
import Breadcrumbs from '~/components/Breadcrumbs'
-import GuidesTableOfContents from '~/components/GuidesTableOfContents'
+import GuidesSidebar from '~/components/GuidesSidebar'
import { TocAnchorsProvider } from '~/features/docs/GuidesMdx.client'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import type { WithRequired } from '~/features/helpers.types'
@@ -71,7 +71,7 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
'relative',
'transition-all ease-out',
'duration-100',
- hideToc ? 'col-span-12' : 'col-span-12 md:col-span-9'
+ 'col-span-12 md:col-span-9'
)}
>
@@ -114,24 +114,23 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
- {!hideToc && (
-
- )}
+
)
diff --git a/apps/docs/features/ui/guide/Guide.tsx b/apps/docs/features/ui/guide/Guide.tsx
index e0179dd7ab3..9fa9b8c31c3 100644
--- a/apps/docs/features/ui/guide/Guide.tsx
+++ b/apps/docs/features/ui/guide/Guide.tsx
@@ -3,7 +3,7 @@
import { createContext, useContext, type ReactNode } from 'react'
import { cn } from 'ui'
-import GuidesTableOfContents from '~/components/GuidesTableOfContents'
+import GuidesTableOfContents from '~/components/GuidesSidebar'
import { TocAnchorsProvider } from '~/features/docs/GuidesMdx.client'
import { type GuideFrontmatter } from '~/lib/docs'
diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts
new file mode 100644
index 00000000000..c07cb27bcb2
--- /dev/null
+++ b/apps/docs/internals/generate-guides-markdown.ts
@@ -0,0 +1,165 @@
+import fs from 'fs'
+import path from 'path'
+import { globby } from 'globby'
+import matter from 'gray-matter'
+
+const PARTIALS_DIR = path.join(process.cwd(), 'content', '_partials')
+
+/**
+ * Reads <$Partial path="..." /> tags and replaces them with the file contents.
+ * Recurses to handle nested partials.
+ */
+async function inlinePartials(content: string): Promise {
+ const partialRegex = /<\$Partial\s+path="([^"]+)"[^/]*\/>/g
+ const matches = [...content.matchAll(partialRegex)]
+ for (const [fullMatch, partialPath] of matches) {
+ try {
+ const raw = await fs.promises.readFile(path.join(PARTIALS_DIR, partialPath), 'utf8')
+ const { content: partialBody } = matter(raw)
+ const inlined = await inlinePartials(partialBody)
+ content = content.replace(fullMatch, inlined)
+ } catch {
+ content = content.replace(fullMatch, '')
+ }
+ }
+ return content
+}
+
+/** Remove the minimum common leading whitespace from all non-empty lines. */
+function dedentBlock(text: string): string {
+ const lines = text.split('\n')
+ const nonEmpty = lines.filter((l) => /\S/.test(l))
+ if (!nonEmpty.length) return text
+ const minIndent = Math.min(...nonEmpty.map((l) => (l.match(/^([ \t]*)/) ?? ['', ''])[1].length))
+ if (!minIndent) return text
+ return lines.map((l) => l.slice(minIndent)).join('\n')
+}
+
+/**
+ * Converts StepHikeCompact components to markdown ordered lists.
+ * Each step becomes a numbered item: the title (from Details) is bolded on the item
+ * line, and the full step body (Details + Code) is dedented and appended below.
+ * Remaining JSX tags inside the body are later stripped by stripJsxTags.
+ */
+function convertStepHike(content: string): string {
+ return content.replace(/([\s\S]*?)<\/StepHikeCompact>/g, (_, body) => {
+ const items: string[] = []
+ const stepRe = /]*>([\s\S]*?)<\/StepHikeCompact\.Step>/g
+ let stepNum = 1
+ let m: RegExpExecArray | null
+ while ((m = stepRe.exec(body)) !== null) {
+ const stepBody = m[1]
+ const titleMatch = stepBody.match(/]+title="([^"]*)"/)
+ const title = titleMatch ? titleMatch[1] : ''
+ // Dedent the entire step body so nested JSX indentation is removed.
+ // Remaining component tags (Details, Code, Admonition…) are stripped later.
+ const inner = dedentBlock(stepBody).trim()
+ const item = title ? `${stepNum}. **${title}**\n\n${inner}` : `${stepNum}. ${inner}`
+ items.push(item)
+ stepNum++
+ }
+ return items.join('\n\n')
+ })
+}
+
+/**
+ * Strips JSX component tags (capitalized names, dot-notation, or $-prefixed)
+ * while keeping their inner content. Also strips wrapper div and a elements.
+ * Removes MDX JSX comment blocks. Strips unnecessary leading indentation from
+ * non-code-block lines.
+ */
+function stripJsxTags(content: string): string {
+ // Remove MDX/JSX comments {/* ... */}
+ content = content.replace(/\{\/\*[\s\S]*?\*\/\}/g, '')
+
+ // Remove self-closing JSX components: or <$Directive ... />
+ content = content.replace(/<[\$A-Z][\w.]*(?:\s[^>]*)?\s*\/>/gs, '')
+
+ // Remove opening JSX component tags (possibly multi-line):
+ content = content.replace(/<[\$A-Z][\w.]*(?:\s[^>]*)?\s*>/gs, '')
+
+ // Remove closing JSX component tags:
+ content = content.replace(/<\/[\$A-Z][\w.]*>/g, '')
+
+ // Remove wrapper div and a elements used structurally in MDX (carry JSX props
+ // like className which are not valid HTML; inner content such as img is preserved)
+ content = content.replace(/]*)?\s*>/g, '')
+ content = content.replace(/<\/div>/g, '')
+ content = content.replace(/
]*)?\s*>/g, '')
+ content = content.replace(/<\/a>/g, '')
+
+ // Split on fenced code blocks to handle prose and code separately.
+ // For prose (even segments): strip leading whitespace (removes JSX nesting indent).
+ // For code blocks (odd segments): dedent the body to remove JSX nesting indent while
+ // preserving relative code structure, then normalize the closing fence.
+ const segments = content.split(/(```[\s\S]*?```)/g)
+ content = segments
+ .map((seg, i) => {
+ if (i % 2 === 0) return seg.replace(/^[ \t]+/gm, '')
+ return seg.replace(
+ /^(```[^\n]*\n)([\s\S]*?)(\n[ \t]*```)$/,
+ (_, open, body) => open + dedentBlock(body) + '\n```'
+ )
+ })
+ .join('')
+
+ // Collapse lines that are only whitespace to empty lines, then deduplicate blank lines
+ content = content.replace(/^[^\S\n]+$/gm, '')
+ content = content.replace(/\n{3,}/g, '\n\n').trim()
+
+ return content
+}
+
+async function generate() {
+ const files = await globby(['content/guides/**/!(_)*.mdx'])
+ let warnings = 0
+
+ await Promise.all(
+ files.map(async (filePath) => {
+ const outPath = filePath
+ .replace(/^content\/guides\//, 'public/docs/guides/')
+ .replace(/\.mdx$/, '.md')
+
+ let output: string
+ try {
+ const raw = await fs.promises.readFile(filePath, 'utf8')
+ const { content: rawContent, data } = matter(raw)
+
+ const withPartials = await inlinePartials(rawContent)
+ const withSteps = convertStepHike(withPartials)
+ const processed = stripJsxTags(withSteps)
+
+ const header = [
+ data.title ? `# ${data.title}` : '',
+ data.subtitle || data.description ? `\n${data.subtitle ?? data.description}` : '',
+ ]
+ .filter(Boolean)
+ .join('\n')
+
+ output = header ? `${header}\n\n${processed}` : processed
+ } catch (err) {
+ warnings++
+ console.warn(
+ `[warn] Failed to process ${filePath}: ${err instanceof Error ? err.message : err}`
+ )
+ // Fall back to raw file content so the route still serves something
+ try {
+ output = await fs.promises.readFile(filePath, 'utf8')
+ } catch {
+ output = ``
+ }
+ }
+
+ // content/guides/ai/vector-columns.mdx → public/docs/guides/ai/vector-columns.md
+ // Placing under public/docs/ ensures the file is served at /docs/guides/...
+ // matching the exact URL of the rendered page.
+ await fs.promises.mkdir(path.dirname(outPath), { recursive: true })
+ await fs.promises.writeFile(outPath, output)
+ })
+ )
+
+ const summary = warnings ? ` (${warnings} with warnings)` : ''
+ console.log(`Generated ${files.length} markdown files under public/docs/guides/${summary}`)
+}
+
+generate()
diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts
index ea55aafa52e..27868037dee 100644
--- a/apps/docs/middleware.ts
+++ b/apps/docs/middleware.ts
@@ -6,8 +6,19 @@ import { BASE_PATH } from '~/lib/constants'
const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference`
+const GUIDES_PATH = `${BASE_PATH ?? ''}/guides`
+
export function middleware(request: NextRequest) {
const url = new URL(request.url)
+
+ // Serve pre-generated .md files before the [[...slug]] page route can intercept them
+ if (url.pathname.startsWith(GUIDES_PATH + '/') && url.pathname.endsWith('.md')) {
+ const slug = url.pathname.slice(GUIDES_PATH.length + 1, -'.md'.length)
+ const rewriteUrl = new URL(url)
+ rewriteUrl.pathname = `${BASE_PATH ?? ''}/api/guides-md/${slug}`
+ return NextResponse.rewrite(rewriteUrl)
+ }
+
if (!url.pathname.startsWith(REFERENCE_PATH)) {
return NextResponse.next()
}
@@ -56,5 +67,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
- matcher: '/reference/:path*',
+ matcher: ['/reference/:path*', '/guides/:path*'],
}
diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs
index d1419a3e104..53e8066039e 100644
--- a/apps/docs/next.config.mjs
+++ b/apps/docs/next.config.mjs
@@ -60,6 +60,7 @@ const nextConfig = {
],
outputFileTracingIncludes: {
'/api/crawlers': ['./features/docs/generated/**/*', './docs/ref/**/*'],
+ '/api/guides-md/**/*': ['./public/docs/guides/**/*'],
'/guides/**/*': ['./content/guides/**/*', './content/troubleshooting/**/*', './examples/**/*'],
'/reference/**/*': ['./features/docs/generated/**/*', './docs/ref/**/*'],
},
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 98acb87d8d3..1f562c8413e 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -7,6 +7,7 @@
"build": "next build",
"build:analyze": "ANALYZE=true next build",
"build:llms": "tsx --conditions=react-server ./scripts/llms.ts",
+ "build:guides-markdown": "tsx ./internals/generate-guides-markdown.ts",
"build:sitemap": "tsx ./internals/generate-sitemap.ts",
"clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__",
"codegen:examples": "shx cp -r ../../examples ./examples",
@@ -25,7 +26,7 @@
"lint": "eslint .",
"lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml",
"postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh",
- "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
+ "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:guides-markdown",
"predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
"preembeddings": "pnpm run codegen:references",
"preinstall": "npx only-allow pnpm",