[GROWTH-773] chore(www): serve llms.txt content via API routes (#44897)

The docs build had a fragile implicit dependency on www's filesystem
(`../../../apps/www/public/llms`), flagged by the docs team in #44670.
Rather than formalising that dependency with a shared package, this PR
eliminates it entirely by making www the sole owner of llms content
assembly.

**How it works now:**

`/llms/[slug]` handles all `/llms/*.txt` requests via a 3-step cascade:
1. Dynamic content — `pricing.txt` generated at request time from
`shared-data` imports
2. Local file — product overviews read from `data/llms/`
3. Docs proxy — reference docs (guides, js, dart, etc.) fetched from the
docs app

No hardcoded slug lists, so adding new content just works.

**What changed:**

- `apps/docs/scripts/llms.ts` trimmed to only generate per-source
reference files — www now owns `llms.txt`, `llms-full.txt`, and product
overviews
- Removed `generateLlmsPricing.mjs` build script — pricing generated
dynamically from `shared-data`
- Removed llms rewrites from `rewrites.js` — routes handle everything
with consistent `Cache-Control: public, s-maxage=3600,
stale-while-revalidate=86400`
- Product overview `.txt` files moved from `public/llms/` → `data/llms/`
so all requests go through routes for consistent caching

**Docs team concerns from GROWTH-773:**

| Concern | Resolution |
|---------|-----------|
| Docs build depends on www files at a fragile relative path | Path
removed — docs no longer reads from www |
| www restructuring breaks docs with no obvious connection | Eliminated
— no cross-app filesystem dependency |
| No build order enforcement between www and docs | Not needed — docs
doesn't depend on www's build output |

## To test

- `curl <preview>/llms.txt` — markdown index with doc + product overview
links
- `curl <preview>/llms-full.txt` — combined product overviews + docs
content
- `curl <preview>/llms/pricing.txt` — dynamically generated pricing
tables
- `curl <preview>/llms/auth.txt` — product overview from local file
- `curl <preview>/llms/guides.txt` — proxied from docs app
- `curl <preview>/llms/nonexistent.txt` — 404
- Verify `Cache-Control` header on all responses

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-04-15 22:45:01 +09:00
1 parent b44c32d05d
commit cb34acd45c
18 files changed
+522 -666

No files matched your search

+1 -3
View File
@@ -27,9 +27,7 @@ yarn-error.log*
# Sitemap
public/sitemap.xml
# llms.txt (generated)
public/llms.txt
public/llms-full.txt
# Per-source llms files (generated by build:llms, served by www)
public/llms/
# Generated guide markdown files
public/docs/
+10 -100
View File
@@ -2,11 +2,9 @@ import './utils/dotenv.js'
import 'dotenv/config'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { isFeatureEnabled } from '../../../packages/common/enabled-features/index.js'
import { getCustomContent } from '../lib/custom-content/getCustomContent.js'
import {
fetchCliLibReferenceSource,
fetchCSharpLibReferenceSource,
@@ -37,12 +35,6 @@ const {
sdkSwift: sdkSwiftEnabled,
} = isFeatureEnabled(['sdk:csharp', 'sdk:dart', 'sdk:kotlin', 'sdk:python', 'sdk:swift'])
const { metadataTitle } = getCustomContent(['metadata:title'])
function toLink(source: Source) {
return `[${source.title}](https://supabase.com/${source.relPath})`
}
const SOURCES: Source[] = [
{
title: 'Supabase Guides',
@@ -115,79 +107,14 @@ const SOURCES: Source[] = [
},
]
// Product overview .txt files are hand-curated and live in apps/www/public/llms/.
// These links always point to production since llms.txt is only meaningful in prod.
const PRODUCT_OVERVIEW_LINKS = [
'- [Supabase Overview](https://supabase.com/llms/homepage.txt)',
'- [Supabase Database](https://supabase.com/llms/database.txt)',
'- [Supabase Auth](https://supabase.com/llms/auth.txt)',
'- [Supabase Storage](https://supabase.com/llms/storage.txt)',
'- [Supabase Edge Functions](https://supabase.com/llms/edge-functions.txt)',
'- [Supabase Realtime](https://supabase.com/llms/realtime.txt)',
'- [Supabase Vector](https://supabase.com/llms/vector.txt)',
'- [Supabase Pricing](https://supabase.com/llms/pricing.txt)',
].join('\n')
async function generateMainLlmsTxt() {
const sourceLinks = SOURCES.filter((source) => source.enabled !== false)
.map((source) => `- ${toLink(source)}`)
.join('\n')
const fullText = [
`# ${metadataTitle}`,
'',
'For the complete documentation in a single file, see [Full Documentation](https://supabase.com/llms-full.txt).',
'',
'## Documentation',
'',
sourceLinks,
'',
'## Product Overview',
'',
PRODUCT_OVERVIEW_LINKS,
].join('\n')
await fs.writeFile('public/llms.txt', fullText)
}
// Product overview .txt files live in apps/www/public/llms/, read at build time.
// Order matters: homepage first, pricing last, products alphabetical in between.
const PRODUCT_LLM_FILES = [
'homepage.txt',
'auth.txt',
'database.txt',
'edge-functions.txt',
'realtime.txt',
'storage.txt',
'vector.txt',
'pricing.txt',
]
const PRODUCT_LLMS_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../apps/www/public/llms'
)
async function readProductLlmContent(): Promise<string> {
const contents = await Promise.all(
PRODUCT_LLM_FILES.map((file) => {
const filePath = path.join(PRODUCT_LLMS_DIR, file)
return fs.readFile(filePath, 'utf-8')
})
)
return contents.join('\n\n---\n\n')
}
async function generateLlmsTxt() {
try {
await fs.mkdir('public/llms', { recursive: true })
const enabledSources = SOURCES.filter((source) => source.enabled !== false)
// Fetch all sources once, reuse for both per-SDK files and llms-full.txt
const [productContent, ...fetchedSources] = await Promise.all([
readProductLlmContent(),
...enabledSources.map(async (sourceDefn) => {
const fetchedSources = await Promise.all(
enabledSources.map(async (sourceDefn) => {
const source = await sourceDefn.fetch()
const sourceText = source
.map((section) => {
@@ -196,33 +123,14 @@ async function generateLlmsTxt() {
})
.join('\n\n')
return { defn: sourceDefn, text: sourceText }
}),
])
})
)
await Promise.all([
generateMainLlmsTxt(),
// Per-SDK files
...fetchedSources.map(({ defn, text }) =>
await Promise.all(
fetchedSources.map(({ defn, text }) =>
fs.writeFile(`public/${defn.relPath}`, `${defn.title}\n\n${text}`)
),
// Combined file: product overview + all docs
fs.writeFile(
'public/llms-full.txt',
[
'# Supabase',
'',
'## Product Overview',
'',
productContent,
'',
'---',
'',
'## Documentation',
'',
fetchedSources.map(({ defn, text }) => `# ${defn.title}\n\n${text}`).join('\n\n---\n\n'),
].join('\n')
),
])
)
)
} catch (err) {
console.error(err)
throw err
@@ -232,3 +140,5 @@ async function generateLlmsTxt() {
if (process.argv[1] === fileURLToPath(import.meta.url)) {
generateLlmsTxt()
}
export { generateLlmsTxt, SOURCES }
+107
View File
@@ -0,0 +1,107 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { isFeatureEnabled } from 'common/enabled-features'
import { generatePricingContent } from '@/lib/llms'
export const dynamic = 'force-dynamic'
interface Source {
title: string
slug: string
enabled: boolean
}
function getSources(): Source[] {
const { sdkCsharp, sdkDart, sdkKotlin, sdkPython, sdkSwift } = isFeatureEnabled([
'sdk:csharp',
'sdk:dart',
'sdk:kotlin',
'sdk:python',
'sdk:swift',
])
return [
{ title: 'Supabase Guides', slug: 'guides', enabled: true },
{ title: 'Supabase Reference (JavaScript)', slug: 'js', enabled: true },
{ title: 'Supabase Reference (Dart)', slug: 'dart', enabled: sdkDart },
{ title: 'Supabase Reference (Swift)', slug: 'swift', enabled: sdkSwift },
{ title: 'Supabase Reference (Kotlin)', slug: 'kotlin', enabled: sdkKotlin },
{ title: 'Supabase Reference (Python)', slug: 'python', enabled: sdkPython },
{ title: 'Supabase Reference (C#)', slug: 'csharp', enabled: sdkCsharp },
{ title: 'Supabase CLI Reference', slug: 'cli', enabled: true },
]
}
// Order matters: homepage first, products alphabetical in between.
// pricing.txt is generated dynamically via generatePricingContent().
const PRODUCT_LLM_FILES = [
'homepage.txt',
'auth.txt',
'database.txt',
'edge-functions.txt',
'realtime.txt',
'storage.txt',
'vector.txt',
]
async function readProductOverviews(): Promise<string> {
const staticContents = await Promise.all(
PRODUCT_LLM_FILES.map((file) => {
const filePath = join(process.cwd(), 'data/llms', file)
return readFile(filePath, 'utf-8')
})
)
const pricingContent = generatePricingContent()
return [...staticContents, pricingContent].join('\n\n---\n\n')
}
async function fetchSourceContent(slug: string): Promise<string | null> {
const docsUrl = process.env.NEXT_PUBLIC_DOCS_URL
if (!docsUrl) return null
const response = await fetch(`${docsUrl}/llms/${slug}.txt`)
if (!response.ok) return null
return response.text()
}
export async function GET() {
const sources = getSources()
const enabledSources = sources.filter((source) => source.enabled)
const [productContent, ...sourceContents] = await Promise.all([
readProductOverviews(),
...enabledSources.map(async (source) => {
const text = await fetchSourceContent(source.slug)
return { title: source.title, text }
}),
])
const docsSection = sourceContents
.filter((s): s is { title: string; text: string } => s.text !== null)
.map(({ title, text }) => `# ${title}\n\n${text}`)
.join('\n\n---\n\n')
const content = [
'# Supabase',
'',
'## Product Overview',
'',
productContent,
'',
'---',
'',
'## Documentation',
'',
docsSection,
].join('\n')
return new Response(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
+71
View File
@@ -0,0 +1,71 @@
import { isFeatureEnabled } from 'common/enabled-features'
export const dynamic = 'force-dynamic'
interface Source {
title: string
relPath: string
enabled: boolean
}
function getSources(): Source[] {
const { sdkCsharp, sdkDart, sdkKotlin, sdkPython, sdkSwift } = isFeatureEnabled([
'sdk:csharp',
'sdk:dart',
'sdk:kotlin',
'sdk:python',
'sdk:swift',
])
return [
{ title: 'Supabase Guides', relPath: 'llms/guides.txt', enabled: true },
{ title: 'Supabase Reference (JavaScript)', relPath: 'llms/js.txt', enabled: true },
{ title: 'Supabase Reference (Dart)', relPath: 'llms/dart.txt', enabled: sdkDart },
{ title: 'Supabase Reference (Swift)', relPath: 'llms/swift.txt', enabled: sdkSwift },
{ title: 'Supabase Reference (Kotlin)', relPath: 'llms/kotlin.txt', enabled: sdkKotlin },
{ title: 'Supabase Reference (Python)', relPath: 'llms/python.txt', enabled: sdkPython },
{ title: 'Supabase Reference (C#)', relPath: 'llms/csharp.txt', enabled: sdkCsharp },
{ title: 'Supabase CLI Reference', relPath: 'llms/cli.txt', enabled: true },
]
}
const PRODUCT_OVERVIEW_LINKS = [
'- [Supabase Overview](https://supabase.com/llms/homepage.txt)',
'- [Supabase Database](https://supabase.com/llms/database.txt)',
'- [Supabase Auth](https://supabase.com/llms/auth.txt)',
'- [Supabase Storage](https://supabase.com/llms/storage.txt)',
'- [Supabase Edge Functions](https://supabase.com/llms/edge-functions.txt)',
'- [Supabase Realtime](https://supabase.com/llms/realtime.txt)',
'- [Supabase Vector](https://supabase.com/llms/vector.txt)',
'- [Supabase Pricing](https://supabase.com/llms/pricing.txt)',
].join('\n')
export async function GET() {
const sources = getSources()
const sourceLinks = sources
.filter((source) => source.enabled)
.map((source) => `- [${source.title}](https://supabase.com/${source.relPath})`)
.join('\n')
const content = [
'# Supabase Docs',
'',
'For the complete documentation in a single file, see [Full Documentation](https://supabase.com/llms-full.txt).',
'',
'## Documentation',
'',
sourceLinks,
'',
'## Product Overview',
'',
PRODUCT_OVERVIEW_LINKS,
].join('\n')
return new Response(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
+49
View File
@@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { generatePricingContent } from '@/lib/llms'
export const dynamic = 'force-dynamic'
function textResponse(content: string) {
return new Response(content, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
if (!slug.endsWith('.txt')) {
return new Response('Not Found', { status: 404 })
}
// 1. Check for dynamically generated content (e.g. pricing)
if (slug === 'pricing.txt') {
return textResponse(generatePricingContent())
}
// 2. Check for a local static file in public/llms/
try {
const filePath = join(process.cwd(), 'data/llms', slug)
const content = await readFile(filePath, 'utf-8')
return textResponse(content)
} catch {
// File doesn't exist locally, fall through
}
// 3. Try fetching from the docs app
const docsUrl = process.env.NEXT_PUBLIC_DOCS_URL
if (docsUrl) {
const response = await fetch(`${docsUrl}/llms/${slug}`)
if (response.ok) {
const content = await response.text()
return textResponse(content)
}
}
return new Response('Not Found', { status: 404 })
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+279
View File
@@ -0,0 +1,279 @@
import { plans } from 'shared-data/plans'
import { pricing } from 'shared-data/pricing'
import addOnTable from '@/data/PricingAddOnTable.json'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type PlanId = 'free' | 'pro' | 'team' | 'enterprise'
const PLAN_IDS: PlanId[] = ['free', 'pro', 'team', 'enterprise']
const PLAN_LABELS: Record<PlanId, string> = {
free: 'Free',
pro: 'Pro',
team: 'Team',
enterprise: 'Enterprise',
}
function formatPlanValue(val: boolean | string | string[]): string {
if (val === true) return 'Included'
if (val === false) return 'Not included'
if (Array.isArray(val)) return val.join(', ')
return String(val)
}
function pad(str: string, len: number): string {
return str.padEnd(len)
}
function getColumnValue(row: { columns: { key: string; value: unknown }[] }, key: string): unknown {
const col = row.columns.find((c) => c.key === key)
if (!col) throw new Error(`Missing column "${key}"`)
return col.value
}
// ---------------------------------------------------------------------------
// Plan Tiers
// ---------------------------------------------------------------------------
function buildPlanTiersSection(): string {
const lines: string[] = ['## Plan Tiers', '']
for (const plan of plans) {
if (plan.planId === 'enterprise') {
lines.push(`### ${plan.name} - custom pricing`)
} else {
lines.push(`### ${plan.name} - from $${plan.priceMonthly}/month`)
}
for (const feature of plan.features) {
if (Array.isArray(feature)) {
lines.push(`- ${feature[0]}` + (feature[1] ? ` (${feature[1]})` : ''))
} else {
lines.push(`- ${feature}`)
}
}
if (plan.footer) {
lines.push(`- Note: ${plan.footer}`)
}
lines.push('')
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Compute Add-Ons
// ---------------------------------------------------------------------------
function buildComputeSection(): string {
const rows = addOnTable.database.rows
const headers = [
'Size',
'$/month',
'CPU',
'Dedicated',
'RAM',
'Direct Connections',
'Pooler Connections',
]
const keys = [
'plan',
'pricing',
'cpu',
'dedicated',
'memory',
'directConnections',
'poolerConnections',
]
const dataRows = rows.map((row) =>
keys.map((key) => {
const val = getColumnValue(row, key)
if (key === 'dedicated') return val ? 'Yes' : 'No'
return String(val)
})
)
const widths = headers.map((h, i) => Math.max(h.length, ...dataRows.map((r) => r[i].length)))
const headerRow = `| ${headers.map((h, i) => pad(h, widths[i])).join(' | ')} |`
const separator = `| ${widths.map((w) => '-'.repeat(w)).join(' | ')} |`
const bodyRows = dataRows.map(
(cells) => `| ${cells.map((c, i) => pad(c, widths[i])).join(' | ')} |`
)
return [
'## Compute Add-Ons',
'',
'All projects run on a compute instance. Pro and Team plans include Micro compute in the base price.',
'',
headerRow,
separator,
...bodyRows,
'',
'Compute is billed hourly. Each project runs its own instance. Pro and Team plans include $10/month in compute credits (covers one Micro instance). Additional projects add their full compute cost.',
'',
].join('\n')
}
// ---------------------------------------------------------------------------
// Disk Storage
// ---------------------------------------------------------------------------
const DISK_TYPES = [
{
name: 'General Purpose',
maxSize: '16 TB',
size: '8 GB included, then $0.125 per GB',
iops: '3,000 IOPS included, then $0.024 per IOPS',
throughput: '125 MB/s included, then $0.095 per MB/s',
durability: '99.9%',
},
{
name: 'High Performance',
maxSize: '60 TB',
size: '$0.195 per GB',
iops: '$0.119 per IOPS',
throughput: 'Scales automatically with IOPS',
durability: '99.999%',
},
]
function buildDiskSection(): string {
const lines: string[] = ['## Disk Storage', '']
for (const disk of DISK_TYPES) {
lines.push(`### ${disk.name}`)
lines.push(`- Max size: ${disk.maxSize}`)
lines.push(`- Size: ${disk.size}`)
lines.push(`- IOPS: ${disk.iops}`)
lines.push(`- Throughput: ${disk.throughput}`)
lines.push(`- Durability: ${disk.durability}`)
lines.push('')
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Add-Ons
// ---------------------------------------------------------------------------
function findFeature(categoryKey: string, featureKey: string) {
const category = pricing[categoryKey as keyof typeof pricing]
const feature = category.features.find((f) => f.key === featureKey)
if (!feature) throw new Error(`Missing pricing feature "${featureKey}" in "${categoryKey}"`)
return feature
}
function buildAddOnsSection(): string {
const addOns = [
[
'Point-in-Time Recovery (PITR)',
formatPlanValue(findFeature('database', 'database.pitr').plans.pro),
],
['Custom Domain', formatPlanValue(findFeature('security', 'security.customDomains').plans.pro)],
[
'Database Branching',
formatPlanValue(findFeature('database', 'database.branching').plans.pro),
],
[
'Advanced MFA (Phone)',
formatPlanValue(findFeature('auth', 'auth.advancedMFAPhone').plans.pro),
],
['SAML/SSO Auth', formatPlanValue(findFeature('auth', 'auth.saml').plans.pro)],
['Log Drains', formatPlanValue(findFeature('security', 'security.logDrain').plans.pro)],
[
'Image Transformations',
formatPlanValue(findFeature('storage', 'storage.transformations').plans.pro),
],
]
const nameWidth = Math.max('Add-on'.length, ...addOns.map(([n]) => n.length))
const priceWidth = Math.max('Price'.length, ...addOns.map(([, p]) => p.length))
const header = `| ${pad('Add-on', nameWidth)} | ${pad('Price', priceWidth)} |`
const separator = `| ${'-'.repeat(nameWidth)} | ${'-'.repeat(priceWidth)} |`
const rows = addOns.map(
([name, price]) => `| ${pad(name, nameWidth)} | ${pad(price, priceWidth)} |`
)
return ['## Add-Ons', '', header, separator, ...rows, ''].join('\n')
}
// ---------------------------------------------------------------------------
// Full Feature Comparison
// ---------------------------------------------------------------------------
function buildFeatureComparisonSection(): string {
const lines: string[] = ['## Full Feature Comparison', '']
for (const [, category] of Object.entries(pricing)) {
lines.push(`### ${category.title}`, '')
const nameWidth = Math.max('Feature'.length, ...category.features.map((f) => f.title.length))
const planWidths = Object.fromEntries(
PLAN_IDS.map((id) => [
id,
Math.max(
PLAN_LABELS[id].length,
...category.features.map((f) => formatPlanValue(f.plans[id]).length)
),
])
)
const headerRow = `| ${pad('Feature', nameWidth)} | ${PLAN_IDS.map((p) => pad(PLAN_LABELS[p], planWidths[p])).join(' | ')} |`
const separatorRow = `| ${'-'.repeat(nameWidth)} | ${PLAN_IDS.map((p) => '-'.repeat(planWidths[p])).join(' | ')} |`
lines.push(headerRow, separatorRow)
for (const feature of category.features) {
const cells = PLAN_IDS.map((p) => pad(formatPlanValue(feature.plans[p]), planWidths[p]))
lines.push(`| ${pad(feature.title, nameWidth)} | ${cells.join(' | ')} |`)
}
lines.push('')
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function generatePricingContent(): string {
return [
'# Supabase Pricing',
'',
'> Start for free, scale as you grow. Pay only for what you use.',
'',
'Supabase offers four plans: Free, Pro, Team, and Enterprise. All plans include unlimited API requests.',
'',
'## How billing works',
'',
'Supabase uses organization-based billing. You choose a plan (Pro, Team, or Enterprise) for your organization, then each project within it runs on its own compute instance. The plan subscription covers platform features and usage quotas. Compute is billed separately per project.',
'',
'Pro and Team plans include $10/month in compute credits, which covers one Micro instance. Additional projects each add their own compute cost. For example, a Pro org with 2 projects on Micro compute costs: $25 (plan) + $10 (project 1) + $10 (project 2) - $10 (credits) = $35/month.',
'',
'For current pricing, visit https://supabase.com/pricing.',
'',
buildPlanTiersSection(),
buildComputeSection(),
buildDiskSection(),
buildAddOnsSection(),
buildFeatureComparisonSection(),
'## Links',
'',
'- Pricing page: https://supabase.com/pricing',
'- Documentation: https://supabase.com/docs/guides/platform/org-based-billing',
'- Dashboard: https://supabase.com/dashboard',
'',
].join('\n')
}
-17
View File
@@ -65,23 +65,6 @@ const rewrites = [
source: '/.well-known/security.txt',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/.well-known/security.txt`,
},
{
source: '/llms.txt',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/llms.txt`,
},
{
source: '/llms-full.txt',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/llms-full.txt`,
},
// Docs-generated LLM source files. Scoped to known doc slugs so that
// marketing .txt files in www/public/llms/ (e.g. database.txt, auth.txt)
// are served directly from public/ without being proxied to the docs app.
// Update this list when adding new SDK sources in apps/docs/scripts/llms.ts.
// Current slugs: guides, js, dart, swift, kotlin, python, csharp, cli
{
source: '/llms/:path((?:guides|js|dart|swift|kotlin|python|csharp|cli)\\.txt)',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/llms/:path`,
},
{ source: '/feed.xml', destination: `/rss.xml` },
]
+4
View File
@@ -71,6 +71,10 @@ const nextConfig = {
'public/**/*',
],
},
outputFileTracingIncludes: {
'/llms-full.txt': ['./data/llms/**/*'],
'/llms/[slug]': ['./data/llms/**/*'],
},
reactStrictMode: true,
images: {
dangerouslyAllowSVG: false,
+1 -1
View File
@@ -13,7 +13,7 @@
"clean": "rimraf node_modules",
"pretypecheck": "next typegen",
"typecheck": "pnpm run content:build && tsc --noEmit",
"content:build": "node scripts/generateStaticContent.mjs && node scripts/generateLlmsPricing.mjs",
"content:build": "node scripts/generateStaticContent.mjs",
"test": "vitest --run",
"test:watch": "vitest watch",
"postbuild": "node ./internals/generate-sitemap.mjs && ./../../scripts/upload-static-assets.sh"
-210
View File
@@ -1,210 +0,0 @@
# Supabase Pricing
> Note: this file is auto-generated from packages/shared-data. Do not edit directly.
> Start for free, scale as you grow. Pay only for what you use.
Supabase offers four plans: Free, Pro, Team, and Enterprise. All plans include unlimited API requests.
## How billing works
Supabase uses organization-based billing. You choose a plan (Pro, Team, or Enterprise) for your organization, then each project within it runs on its own compute instance. The plan subscription covers platform features and usage quotas. Compute is billed separately per project.
Pro and Team plans include $10/month in compute credits, which covers one Micro instance. Additional projects each add their own compute cost. For example, a Pro org with 2 projects on Micro compute costs: $25 (plan) + $10 (project 1) + $10 (project 2) - $10 (credits) = $35/month.
For current pricing, visit https://supabase.com/pricing.
## Plan Tiers
### Free - from $0/month
- Unlimited API requests
- 50,000 monthly active users
- 500 MB database size (Shared CPU • 500 MB RAM)
- 5 GB egress
- 5 GB cached egress
- 1 GB file storage
- Community support
- Note: Free projects are paused after 1 week of inactivity. Limit of 2 active projects.
### Pro - from $25/month
- 100,000 monthly active users (then $0.00325 per MAU)
- 8 GB disk size per project (then $0.125 per GB)
- 250 GB egress (then $0.09 per GB)
- 250 GB cached egress (then $0.03 per GB)
- 100 GB file storage (then $0.021 per GB)
- Email support
- Daily backups stored for 7 days
- 7-day log retention
- Add Log Drains (additional $60 per drain, per project)
### Team - from $599/month
- SOC2
- Project-scoped and read-only access
- HIPAA available as paid add-on
- SSO for Supabase Dashboard
- Priority email support & SLAs
- Daily backups stored for 14 days
- 28-day log retention
### Enterprise - custom pricing
- Designated Support manager
- Uptime SLAs
- BYO Cloud supported
- 24×7×365 premium enterprise support
- Private Slack channel
- Custom Security Questionnaires
## Compute Add-Ons
All projects run on a compute instance. Pro and Team plans include Micro compute in the base price.
| Size | $/month | CPU | Dedicated | RAM | Direct Connections | Pooler Connections |
| ------ | ---------- | ----------- | --------- | ------ | ------------------ | ------------------ |
| Micro | $10 | 2-core ARM | No | 1 GB | 60 | 200 |
| Small | $15 | 2-core ARM | No | 2 GB | 90 | 400 |
| Medium | $60 | 2-core ARM | No | 4 GB | 120 | 600 |
| Large | $110 | 2-core ARM | Yes | 8 GB | 160 | 800 |
| XL | $210 | 4-core ARM | Yes | 16 GB | 240 | 1,000 |
| 2XL | $410 | 8-core ARM | Yes | 32 GB | 380 | 1,500 |
| 4XL | $960 | 16-core ARM | Yes | 64 GB | 480 | 3,000 |
| 8XL | $1,870 | 32-core ARM | Yes | 128 GB | 490 | 6,000 |
| 12XL | $2,800 | 48-core ARM | Yes | 192 GB | 500 | 9,000 |
| 16XL | $3,730 | 64-core ARM | Yes | 256 GB | 500 | 12,000 |
| >16XL | Contact Us | Custom | Yes | Custom | Custom | Custom |
Compute is billed hourly. Each project runs its own instance. Pro and Team plans include $10/month in compute credits (covers one Micro instance). Additional projects add their full compute cost.
## Disk Storage
### General Purpose
- Max size: 16 TB
- Size: 8 GB included, then $0.125 per GB
- IOPS: 3,000 IOPS included, then $0.024 per IOPS
- Throughput: 125 MB/s included, then $0.095 per MB/s
- Durability: 99.9%
### High Performance
- Max size: 60 TB
- Size: $0.195 per GB
- IOPS: $0.119 per IOPS
- Throughput: Scales automatically with IOPS
- Durability: 99.999%
## Add-Ons
| Add-on | Price |
| ----------------------------- | --------------------------------------------------------------------------- |
| Point-in-Time Recovery (PITR) | $100 per month per 7 days retention |
| Custom Domain | $10 per domain per month per project add on |
| Database Branching | $0.01344 per branch, per hour |
| Advanced MFA (Phone) | $75 per month for first project, then $10 per month per additional projects |
| SAML/SSO Auth | 50 included, then $0.015 per MAU |
| Log Drains | $60 per drain per month, + $0.20 per million events, + $0.09 per GB egress |
| Image Transformations | 100 origin images included, then $5 per 1000 origin images |
## Full Feature Comparison
### Database
| Feature | Free | Pro | Team | Enterprise |
| --------------------------- | ----------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
| Dedicated Postgres Database | Included | Included | Included | Included |
| Unlimited API requests | Included | Included | Included | Included |
| Database size | 500 MB database size per project included | 8 GB disk size per project included, then $0.125 per GB | 8 GB disk size per project included, then $0.125 per GB | Custom |
| Advanced disk config | Not included | Included | Included | Included |
| Automatic backups | Not included | 7 days | 14 days | Custom |
| Point in time recovery | Not included | $100 per month per 7 days retention | $100 per month per 7 days retention | $100 per month per 7 days retention, >28 days retention available |
| Pausing | After 1 week of inactivity | Never | Never | Never |
| Branching | Not included | $0.01344 per branch, per hour | $0.01344 per branch, per hour | Custom |
| Egress | 5 GB included | 250 GB included, then $0.09 per GB | 250 GB included, then $0.09 per GB | Custom |
### Auth
| Feature | Free | Pro | Team | Enterprise |
| ------------------------------------ | ------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------- |
| Total Users | Unlimited | Unlimited | Unlimited | Unlimited |
| MAUs | 50,000 included | 100,000 included, then $0.00325 per MAU | 100,000 included, then $0.00325 per MAU | Custom |
| User data ownership | Included | Included | Included | Included |
| Anonymous Sign-ins | Included | Included | Included | Included |
| Social OAuth providers | Included | Included | Included | Included |
| Custom SMTP server | Included | Included | Included | Included |
| Remove Supabase branding from emails | Not included | Included | Included | Included |
| Auth Audit Logs | 1 hour | 7 days | 28 days | Included |
| Basic Multi-Factor Auth | Included | Included | Included | Included |
| Advanced Multi-Factor Auth - Phone | Not included | $75 per month for first project, then $10 per month per additional projects | $75 per month for first project, then $10 per month per additional projects | Custom |
| Third-Party MAUs | 50,000 included | 100,000 included, then $0.00325 per MAU | 100,000 included, then $0.00325 per MAU | Custom |
| Single Sign-On (SAML 2.0) | Not included | 50 included, then $0.015 per MAU | 50 included, then $0.015 per MAU | Contact Us |
| Leaked password protection | Not included | Included | Included | Included |
| Single session per user | Not included | Included | Included | Included |
| Session timeouts | Not included | Included | Included | Included |
| Auth Hooks | Custom Access Token (JWT), Send custom email/SMS | Custom Access Token (JWT), Send custom email/SMS | All | All |
| Advanced security features | Not included | Not included | Not included | Contact Us |
### Storage
| Feature | Free | Pro | Team | Enterprise |
| ------------------------------- | ------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ---------- |
| Storage | 1 GB included | 100 GB included, then $0.021 per GB | 100 GB included, then $0.021 per GB | Custom |
| Cached Egress | 5 GB included | 250 GB included, then $0.03 per GB | 250 GB included, then $0.03 per GB | Custom |
| Custom access controls | Included | Included | Included | Included |
| Max file upload size | 50 MB | 500 GB | 500 GB | Custom |
| Content Delivery Network | Basic CDN | Smart CDN | Smart CDN | Smart CDN |
| Image Transformations | Not included | 100 origin images included, then $5 per 1000 origin images | 100 origin images included, then $5 per 1000 origin images | Custom |
| Bring your own storage provider | Not included | Not included | Not included | Included |
### Edge Functions
| Feature | Free | Pro | Team | Enterprise |
| ----------- | ---------------- | ----------------------------------------- | ----------------------------------------- | ---------- |
| Invocations | 500,000 included | 2 Million included, then $2 per 1 Million | 2 Million included, then $2 per 1 Million | Custom |
### Realtime
| Feature | Free | Pro | Team | Enterprise |
| --------------------------- | ------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------------- |
| Postgres Changes | Included | Included | Included | Included |
| Concurrent Peak Connections | 200 included | 500 included, then $10 per 1000 | 500 included, then $10 per 1000 | Custom concurrent connections and volume discount |
| Messages Per Month | 2 Million included | 5 Million included, then $2.50 per Million | 5 Million included, then $2.50 per Million | Volume discounts on messages |
| Max Message Size | 256 KB | 3 MB | 3 MB | Custom |
### Dashboard
| Feature | Free | Pro | Team | Enterprise |
| ------------ | --------- | --------- | --------- | ---------- |
| Team members | Unlimited | Unlimited | Unlimited | Unlimited |
### Platform Security and Compliance
| Feature | Free | Pro | Team | Enterprise |
| ------------------------------ | ----------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------ |
| BYO cloud | Not included | Not included | Not included | Included |
| Log retention (API & Database) | 1 day | 7 days | 28 days | 90 days |
| Log Drain | Not included | $60 per drain per month, + $0.20 per million events, + $0.09 per GB egress | $60 per drain per month, + $0.20 per million events, + $0.09 per GB egress | Custom |
| Platform Audit Logs | Not included | Not included | Included | Included |
| Metrics endpoint | Not included | Included | Included | Included |
| SOC2 | Not included | Not included | Included | Included |
| HIPAA | Not included | Not included | Available as paid add-on | Available as paid add-on |
| AWS PrivateLink | Not included | Not included | Included | Included |
| SSO | Not included | Not included | Contact Us | Contact Us |
| Uptime SLAs | Not included | Not included | Not included | Included |
| Access Roles | Owner, Admin, Developer | Owner, Admin, Developer | Owner, Admin, Developer, Read-only, Predefined project scoped roles | Custom project scoped roles |
| Vanity URLs | Not included | Included | Included | Included |
| Custom Domains | Not included | $10 per domain per month per project add on | $10 per domain per month per project add on | 1, additional $10/domain/month |
### Support
| Feature | Free | Pro | Team | Enterprise |
| -------------------------------- | ------------ | ------------ | ------------ | ---------- |
| Community Support | Included | Included | Included | Included |
| Email Support | Not included | Included | Included | Included |
| Email Support SLA | Not included | Not included | Included | Included |
| Designated support | Not included | Not included | Not included | Included |
| On Boarding Support | Not included | Not included | Not included | Included |
| Designated Customer Success Team | Not included | Not included | Not included | Included |
| Security Questionnaire Help | Not included | Not included | Included | Included |
## Links
- Pricing page: https://supabase.com/pricing
- Documentation: https://supabase.com/docs/guides/platform/org-based-billing
- Dashboard: https://supabase.com/dashboard
-335
View File
@@ -1,335 +0,0 @@
// @ts-check
/**
* Generates apps/www/public/llms/pricing.txt from structured pricing data.
*
* Source data:
* - packages/shared-data/plans.ts (plan tiers, features, cta copy)
* - packages/shared-data/pricing.ts (per-feature plan values)
* - apps/www/data/PricingAddOnTable.json (compute add-on rows)
* - apps/www/components/Pricing/PricingDiskSection.tsx (disk type data - inlined below)
*
* Run: node apps/www/scripts/generateLlmsPricing.mjs
* (Uses tsx for TypeScript imports, falling back to inlined data if unavailable)
*/
import { execSync } from 'child_process'
import { promises as fs } from 'fs'
import os from 'os'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '../../..')
// ---------------------------------------------------------------------------
// Load TypeScript source data via tsx
// ---------------------------------------------------------------------------
// We shell out to tsx to load the TypeScript modules and dump them as JSON.
// tsx is available in the monorepo node_modules.
const tsxBin = path.join(repoRoot, 'node_modules/.bin/tsx')
const loadTsData = async (tsFile, exportName) => {
const tmpFile = path.join(os.tmpdir(), `generate-llms-pricing-${exportName}-${Date.now()}.ts`)
await fs.writeFile(
tmpFile,
`import { ${exportName} } from '${tsFile}'\nprocess.stdout.write(JSON.stringify(${exportName}))\n`,
'utf8'
)
try {
const result = execSync(`"${tsxBin}" "${tmpFile}"`, { encoding: 'utf8', cwd: repoRoot })
return JSON.parse(result)
} finally {
await fs.unlink(tmpFile).catch(() => {})
}
}
const plans = await loadTsData(path.join(repoRoot, 'packages/shared-data/plans.ts'), 'plans')
const pricing = await loadTsData(path.join(repoRoot, 'packages/shared-data/pricing.ts'), 'pricing')
const addOnTablePath = path.join(__dirname, '../data/PricingAddOnTable.json')
const addOnTable = JSON.parse(await fs.readFile(addOnTablePath, 'utf8'))
// ---------------------------------------------------------------------------
// Disk types (sourced from PricingDiskSection.tsx - inlined here for
// portability; keep in sync if that component's data changes)
// ---------------------------------------------------------------------------
const diskTypes = [
{
name: 'General Purpose',
maxSize: '16 TB',
size: '8 GB included, then $0.125 per GB',
iops: '3,000 IOPS included, then $0.024 per IOPS',
throughput: '125 MB/s included, then $0.095 per MB/s',
durability: '99.9%',
},
{
name: 'High Performance',
maxSize: '60 TB',
size: '$0.195 per GB',
iops: '$0.119 per IOPS',
throughput: 'Scales automatically with IOPS',
durability: '99.999%',
},
]
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const formatPlanValue = (val) => {
if (val === true) return 'Included'
if (val === false) return 'Not included'
if (Array.isArray(val)) return val.join(', ')
return String(val)
}
const getVal = (row, key) => {
const col = row.columns.find((c) => c.key === key)
if (!col)
throw new Error(`Missing column "${key}" in compute add-on row "${row.title || 'unknown'}"`)
return col.value
}
const pad = (str, len) => String(str).padEnd(len)
const getPlanValue = (feature, planId) => {
if (!(planId in feature.plans)) {
throw new Error(`Missing plan value for "${planId}" in feature "${feature.key}"`)
}
return formatPlanValue(feature.plans[planId])
}
// ---------------------------------------------------------------------------
// Plan Tiers section
// ---------------------------------------------------------------------------
const buildPlanTiersSection = () => {
const lines = ['## Plan Tiers', '']
for (const plan of plans) {
if (plan.planId === 'enterprise') {
lines.push(`### ${plan.name} - custom pricing`)
} else {
lines.push(`### ${plan.name} - from $${plan.priceMonthly}/month`)
}
for (const feature of plan.features) {
if (Array.isArray(feature)) {
lines.push(`- ${feature[0]}` + (feature[1] ? ` (${feature[1]})` : ''))
} else {
lines.push(`- ${feature}`)
}
}
if (plan.footer) {
lines.push(`- Note: ${plan.footer}`)
}
lines.push('')
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Compute Add-Ons section
// ---------------------------------------------------------------------------
const buildComputeSection = () => {
const rows = addOnTable.database.rows
const colWidths = {
plan: 'Size'.length,
price: '$/month'.length,
cpu: 'CPU'.length,
dedicated: 'Dedicated'.length,
memory: 'RAM'.length,
direct: 'Direct Connections'.length,
pooler: 'Pooler Connections'.length,
}
for (const row of rows) {
colWidths.plan = Math.max(colWidths.plan, String(getVal(row, 'plan')).length)
colWidths.price = Math.max(colWidths.price, String(getVal(row, 'pricing')).length)
colWidths.cpu = Math.max(colWidths.cpu, String(getVal(row, 'cpu')).length)
colWidths.dedicated = Math.max(
colWidths.dedicated,
getVal(row, 'dedicated') ? 'Yes'.length : 'No'.length
)
colWidths.memory = Math.max(colWidths.memory, String(getVal(row, 'memory')).length)
colWidths.direct = Math.max(colWidths.direct, String(getVal(row, 'directConnections')).length)
colWidths.pooler = Math.max(colWidths.pooler, String(getVal(row, 'poolerConnections')).length)
}
const header = `| ${pad('Size', colWidths.plan)} | ${pad('$/month', colWidths.price)} | ${pad('CPU', colWidths.cpu)} | ${pad('Dedicated', colWidths.dedicated)} | ${pad('RAM', colWidths.memory)} | ${pad('Direct Connections', colWidths.direct)} | ${pad('Pooler Connections', colWidths.pooler)} |`
const separator = `| ${'-'.repeat(colWidths.plan)} | ${'-'.repeat(colWidths.price)} | ${'-'.repeat(colWidths.cpu)} | ${'-'.repeat(colWidths.dedicated)} | ${'-'.repeat(colWidths.memory)} | ${'-'.repeat(colWidths.direct)} | ${'-'.repeat(colWidths.pooler)} |`
const dataRows = rows.map((row) => {
const plan = String(getVal(row, 'plan'))
const price = String(getVal(row, 'pricing'))
const cpu = String(getVal(row, 'cpu'))
const dedicated = getVal(row, 'dedicated') ? 'Yes' : 'No'
const memory = String(getVal(row, 'memory'))
const direct = String(getVal(row, 'directConnections'))
const pooler = String(getVal(row, 'poolerConnections'))
return `| ${pad(plan, colWidths.plan)} | ${pad(price, colWidths.price)} | ${pad(cpu, colWidths.cpu)} | ${pad(dedicated, colWidths.dedicated)} | ${pad(memory, colWidths.memory)} | ${pad(direct, colWidths.direct)} | ${pad(pooler, colWidths.pooler)} |`
})
return [
'## Compute Add-Ons',
'',
'All projects run on a compute instance. Pro and Team plans include Micro compute in the base price.',
'',
header,
separator,
...dataRows,
'',
'Compute is billed hourly. Each project runs its own instance. Pro and Team plans include $10/month in compute credits (covers one Micro instance). Additional projects add their full compute cost.',
'',
].join('\n')
}
// ---------------------------------------------------------------------------
// Disk Storage section
// ---------------------------------------------------------------------------
const buildDiskSection = () => {
const lines = ['## Disk Storage', '']
for (const disk of diskTypes) {
lines.push(`### ${disk.name}`)
lines.push(`- Max size: ${disk.maxSize}`)
lines.push(`- Size: ${disk.size}`)
lines.push(`- IOPS: ${disk.iops}`)
lines.push(`- Throughput: ${disk.throughput}`)
lines.push(`- Durability: ${disk.durability}`)
lines.push('')
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Add-Ons section (from pricing.ts feature values)
// ---------------------------------------------------------------------------
const findFeature = (category, key) => {
const feature = pricing[category].features.find((f) => f.key === key)
if (!feature) throw new Error(`Missing pricing feature "${key}" in category "${category}"`)
return feature
}
const buildAddOnsSection = () => {
const addOns = [
[
'Point-in-Time Recovery (PITR)',
getPlanValue(findFeature('database', 'database.pitr'), 'pro'),
],
['Custom Domain', getPlanValue(findFeature('security', 'security.customDomains'), 'pro')],
['Database Branching', getPlanValue(findFeature('database', 'database.branching'), 'pro')],
['Advanced MFA (Phone)', getPlanValue(findFeature('auth', 'auth.advancedMFAPhone'), 'pro')],
['SAML/SSO Auth', getPlanValue(findFeature('auth', 'auth.saml'), 'pro')],
['Log Drains', getPlanValue(findFeature('security', 'security.logDrain'), 'pro')],
[
'Image Transformations',
getPlanValue(findFeature('storage', 'storage.transformations'), 'pro'),
],
]
const nameWidth = Math.max('Add-on'.length, ...addOns.map(([n]) => n.length))
const priceWidth = Math.max('Price'.length, ...addOns.map(([, p]) => p.length))
const header = `| ${pad('Add-on', nameWidth)} | ${pad('Price', priceWidth)} |`
const separator = `| ${'-'.repeat(nameWidth)} | ${'-'.repeat(priceWidth)} |`
const dataRows = addOns.map(
([name, price]) => `| ${pad(name, nameWidth)} | ${pad(price, priceWidth)} |`
)
return ['## Add-Ons', '', header, separator, ...dataRows, ''].join('\n')
}
// ---------------------------------------------------------------------------
// Full feature comparison (per category from pricing.ts)
// ---------------------------------------------------------------------------
const buildFeatureComparisonSection = () => {
const planIds = ['free', 'pro', 'team', 'enterprise']
const planLabels = { free: 'Free', pro: 'Pro', team: 'Team', enterprise: 'Enterprise' }
const lines = ['## Full Feature Comparison', '']
for (const [, category] of Object.entries(pricing)) {
lines.push(`### ${category.title}`, '')
const nameWidth = Math.max('Feature'.length, ...category.features.map((f) => f.title.length))
const planWidths = {}
for (const planId of planIds) {
planWidths[planId] = Math.max(
planLabels[planId].length,
...category.features.map((f) => getPlanValue(f, planId).length)
)
}
const headerRow = `| ${pad('Feature', nameWidth)} | ${planIds.map((p) => pad(planLabels[p], planWidths[p])).join(' | ')} |`
const separatorRow = `| ${'-'.repeat(nameWidth)} | ${planIds.map((p) => '-'.repeat(planWidths[p])).join(' | ')} |`
lines.push(headerRow, separatorRow)
for (const feature of category.features) {
const cells = planIds.map((p) => pad(getPlanValue(feature, p), planWidths[p]))
lines.push(`| ${pad(feature.title, nameWidth)} | ${cells.join(' | ')} |`)
}
lines.push('')
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Assemble final output
// ---------------------------------------------------------------------------
const output = [
'# Supabase Pricing',
'',
'> Note: this file is auto-generated from packages/shared-data. Do not edit directly.',
'',
'> Start for free, scale as you grow. Pay only for what you use.',
'',
'Supabase offers four plans: Free, Pro, Team, and Enterprise. All plans include unlimited API requests.',
'',
'## How billing works',
'',
'Supabase uses organization-based billing. You choose a plan (Pro, Team, or Enterprise) for your organization, then each project within it runs on its own compute instance. The plan subscription covers platform features and usage quotas. Compute is billed separately per project.',
'',
'Pro and Team plans include $10/month in compute credits, which covers one Micro instance. Additional projects each add their own compute cost. For example, a Pro org with 2 projects on Micro compute costs: $25 (plan) + $10 (project 1) + $10 (project 2) - $10 (credits) = $35/month.',
'',
'For current pricing, visit https://supabase.com/pricing.',
'',
buildPlanTiersSection(),
buildComputeSection(),
buildDiskSection(),
buildAddOnsSection(),
buildFeatureComparisonSection(),
'## Links',
'',
'- Pricing page: https://supabase.com/pricing',
'- Documentation: https://supabase.com/docs/guides/platform/org-based-billing',
'- Dashboard: https://supabase.com/dashboard',
'',
].join('\n')
// ---------------------------------------------------------------------------
// Write output
// ---------------------------------------------------------------------------
const outputPath = path.join(__dirname, '../public/llms/pricing.txt')
await fs.writeFile(outputPath, output, 'utf8')
console.log(`✅ Generated ${outputPath}`)