Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/docs/app/api/search/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
}
56 changes: 56 additions & 0 deletions apps/docs/app/api/search/embeddings/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { POST } from './route'

const isFeatureEnabledMock = vi.fn().mockReturnValue(true)
vi.mock('common/enabled-features', () => ({
isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args),
}))

const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)

function makeRequest(body: unknown) {
return new NextRequest('https://example.com/api/search/embeddings', {
method: 'POST',
body: JSON.stringify(body),
})
}

describe('/api/search/embeddings', () => {
afterEach(() => {
vi.clearAllMocks()
})

it('returns 400 when query is missing', async () => {
const response = await POST(makeRequest({}))
expect(response.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})

it('forwards the query and feature flag to the search-embeddings function', async () => {
isFeatureEnabledMock.mockReturnValue(false)
fetchMock.mockResolvedValue(
new Response(JSON.stringify([{ id: 1, path: '/guides/test' }]), { status: 200 })
)

const response = await POST(makeRequest({ query: 'realtime' }))

expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toContain('/functions/v1/search-embeddings')
expect(JSON.parse(init.body)).toEqual({ query: 'realtime', useAlternateSearchIndex: true })
expect(response.status).toBe(200)
expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }])
})

it('propagates the upstream status on error', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'boom' }), { status: 500 }))

const response = await POST(makeRequest({ query: 'realtime' }))

expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'boom' })
})
})
26 changes: 26 additions & 0 deletions apps/docs/app/api/search/embeddings/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs'
import { type NextRequest } from 'next/server'

import { corsHeaders } from '../cors'
import { _handleEmbeddingsSearchRequest } from './route.utils'

export const runtime = 'edge'

export async function OPTIONS() {
return new Response(null, { headers: corsHeaders })
}

export async function POST(request: NextRequest) {
try {
const response = await _handleEmbeddingsSearchRequest(request)
Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value))
return response
} catch (error) {
console.error('Error handling docs embeddings search request:', error)
Sentry.captureException(error, { tags: { route: 'search-embeddings' } })
return Response.json(
{ error: 'There was an error processing your request' },
{ status: 500, headers: corsHeaders }
)
}
}
33 changes: 33 additions & 0 deletions apps/docs/app/api/search/embeddings/route.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as Sentry from '@sentry/nextjs'
import { isFeatureEnabled } from 'common/enabled-features'
import { type NextRequest } from 'next/server'

const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL

export async function _handleEmbeddingsSearchRequest(request: NextRequest) {
const { query } = await request.json()

if (!query || typeof query !== 'string') {
return Response.json({ error: 'Missing query in request data' }, { status: 400 })
}

const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex')

const response = await fetch(`${SUPABASE_URL}/functions/v1/search-embeddings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, useAlternateSearchIndex }),
})

const data = await response.json()

if (!response.ok) {
console.error('Error running docs embeddings search:', data)
Sentry.captureException(new Error(data?.error ?? 'search-embeddings request failed'), {
tags: { route: 'search-embeddings' },
extra: { query, status: response.status, data },
})
}

return Response.json(data, { status: response.status })
}
62 changes: 62 additions & 0 deletions apps/docs/app/api/search/fts/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest } from 'next/server'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { POST } from './route'

const rpcSpy = vi.fn()
vi.mock('~/lib/supabase', () => ({
supabase: () => ({ rpc: rpcSpy }),
}))

const isFeatureEnabledMock = vi.fn().mockReturnValue(true)
vi.mock('common/enabled-features', () => ({
isFeatureEnabled: (...args: unknown[]) => isFeatureEnabledMock(...args),
}))

function makeRequest(body: unknown) {
return new NextRequest('https://example.com/api/search/fts', {
method: 'POST',
body: JSON.stringify(body),
})
}

describe('/api/search/fts', () => {
afterEach(() => {
vi.clearAllMocks()
})

it('returns 400 when query is missing', async () => {
const response = await POST(makeRequest({}))
expect(response.status).toBe(400)
expect(rpcSpy).not.toHaveBeenCalled()
})

it('calls docs_search_fts when the full index feature is enabled', async () => {
isFeatureEnabledMock.mockReturnValue(true)
rpcSpy.mockResolvedValue({ data: [{ id: 1, path: '/guides/test' }], error: null })

const response = await POST(makeRequest({ query: ' realtime ' }))

expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts', { query: 'realtime' })
expect(response.status).toBe(200)
expect(await response.json()).toEqual([{ id: 1, path: '/guides/test' }])
})

it('calls docs_search_fts_nimbus when the full index feature is disabled', async () => {
isFeatureEnabledMock.mockReturnValue(false)
rpcSpy.mockResolvedValue({ data: [], error: null })

await POST(makeRequest({ query: 'realtime' }))

expect(rpcSpy).toHaveBeenCalledWith('docs_search_fts_nimbus', { query: 'realtime' })
})

it('returns 500 when the RPC errors', async () => {
rpcSpy.mockResolvedValue({ data: null, error: { message: 'boom' } })

const response = await POST(makeRequest({ query: 'realtime' }))

expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'boom' })
})
})
26 changes: 26 additions & 0 deletions apps/docs/app/api/search/fts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/nextjs'
import { type NextRequest } from 'next/server'

import { corsHeaders } from '../cors'
import { _handleFtsSearchRequest } from './route.utils'

export const runtime = 'edge'

export async function OPTIONS() {
return new Response(null, { headers: corsHeaders })
}

export async function POST(request: NextRequest) {
try {
const response = await _handleFtsSearchRequest(request)
Object.entries(corsHeaders).forEach(([key, value]) => response.headers.set(key, value))
return response
} catch (error) {
console.error('Error handling docs full-text search request:', error)
Sentry.captureException(error, { tags: { route: 'search-fts' } })
return Response.json(
{ error: 'There was an error processing your request' },
{ status: 500, headers: corsHeaders }
)
}
}
28 changes: 28 additions & 0 deletions apps/docs/app/api/search/fts/route.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as Sentry from '@sentry/nextjs'
import { supabase } from '~/lib/supabase'
import { isFeatureEnabled } from 'common/enabled-features'
import { type NextRequest } from 'next/server'

export async function _handleFtsSearchRequest(request: NextRequest) {
const { query } = await request.json()

if (!query || typeof query !== 'string') {
return Response.json({ error: 'Missing query in request data' }, { status: 400 })
}

const useAlternateSearchIndex = !isFeatureEnabled('search:fullIndex')
const searchFunction = useAlternateSearchIndex ? 'docs_search_fts_nimbus' : 'docs_search_fts'

const { data, error } = await supabase().rpc(searchFunction, { query: query.trim() })

if (error) {
console.error('Error running docs full-text search:', error)
Sentry.captureException(new Error(error.message), {
tags: { route: 'search-fts' },
extra: { query, searchFunction, error },
})
return Response.json({ error: error.message }, { status: 500 })
}

return Response.json(data)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3073,34 +3073,34 @@ export const self_hosting: NavMenuConstant = {
url: '/guides/self-hosting',
items: [
{ name: 'Overview', url: '/guides/self-hosting' },
{ name: 'Self-Hosting with Docker', url: '/guides/self-hosting/docker' },
{ name: 'Deploy with Docker', url: '/guides/self-hosting/docker' },
{ name: 'Configure new API keys', url: '/guides/self-hosting/self-hosted-auth-keys' },
{ name: 'Enable Envoy API Gateway', url: '/guides/self-hosting/self-hosted-envoy' },
{
name: 'Add Reverse Proxy with HTTPS',
url: '/guides/self-hosting/self-hosted-proxy-https',
},
{
name: 'How-to Guides',
items: [
{ name: 'Configure new API keys', url: '/guides/self-hosting/self-hosted-auth-keys' },
{ name: 'Self-Hosted Functions', url: '/guides/self-hosting/self-hosted-functions' },
{
name: 'Add Reverse Proxy with HTTPS',
url: '/guides/self-hosting/self-hosted-proxy-https',
},
{ name: 'Envoy API Gateway', url: '/guides/self-hosting/self-hosted-envoy' },
{ name: 'Upgrade to Postgres 17', url: '/guides/self-hosting/postgres-upgrade-17' },
{
name: 'Restore Project from Platform',
url: '/guides/self-hosting/restore-from-platform',
},
{ name: 'Remove superuser access', url: '/guides/self-hosting/remove-superuser-access' },
{ name: 'Run Self-Hosted Functions', url: '/guides/self-hosting/self-hosted-functions' },
{ name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' },
{ name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' },
{ name: 'Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' },
{ name: 'Add Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' },
{ name: 'Configure Social Login (OAuth)', url: '/guides/self-hosting/self-hosted-oauth' },
{ name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' },
{ name: 'Configure SAML 2.0 SSO', url: '/guides/self-hosting/self-hosted-saml-sso' },
{ name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' },
{ name: 'Remove superuser access', url: '/guides/self-hosting/remove-superuser-access' },
{
name: 'Custom Postgres Extensions',
url: '/guides/self-hosting/custom-postgres-extensions',
},
{
name: 'Restore Project from Platform',
url: '/guides/self-hosting/restore-from-platform',
},
{ name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' },
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: 'Custom Email Templates'
description: 'Configure custom email templates with self-hosted Supabase instance'
subtitle: 'Configure custom email templates with self-hosted Supabase instance'
description: 'Configure custom email templates with self-hosted Supabase instance.'
subtitle: 'Configure custom email templates with self-hosted Supabase instance.'
---

When running a self-hosted Supabase instance, you can fully customize emails sent by Supabase Auth.
Expand Down
22 changes: 8 additions & 14 deletions apps/docs/content/guides/self-hosting/docker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,11 @@ mkdir supabase-project
# ├── supabase
# └── supabase-project

# Copy the compose files over to your project
cp -rf supabase/docker/* supabase-project
# Copy the configuration to your project
cp -rf supabase/docker/. supabase-project

# Copy the example environment file
cp supabase/docker/.env.example supabase-project/.env

# Switch to your project directory
cd supabase-project
# Switch to the project directory and create a .env from the example
cd supabase-project && cp .env.example .env

# Pull the latest images
docker compose pull
Expand Down Expand Up @@ -167,14 +164,11 @@ mkdir supabase-project
# ├── supabase
# └── supabase-project

# Copy the compose files over to your project
cp -rf supabase/docker/* supabase-project

# Copy the example environment file
cp supabase/docker/.env.example supabase-project/.env
# Copy the configuration over to your project
cp -rf supabase/docker/. supabase-project

# Switch to your project directory
cd supabase-project
# Switch to the project directory and create a .env from the example
cd supabase-project && cp .env.example .env

# Pull the latest images
docker compose pull
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: 'Remove superuser access from Studio'
title: 'Remove Superuser Access from Studio'
description: 'Learn how to switch from the supabase_admin to postgres role in self-hosted Supabase.'
subtitle: 'Learn how to switch from the supabase_admin to postgres role in self-hosted Supabase.'
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title = "Realtime: ClientPresenceRateLimitReached error"
date_created = "2026-07-30T00:00:00+00:00"
topics = [ "realtime" ]
keywords = [ "presence", "track", "untrack", "rate limit", "ClientPresenceRateLimitReached", "cursor", "high-frequency", "broadcast", "shutdown" ]

[[errors]]
code = "ClientPresenceRateLimitReached"
message = "Client presence rate limit exceeded"
---

If a client sends Presence updates too frequently, you may see this error code in your [Realtime logs](/dashboard/project/_/logs/realtime-logs):

```
ClientPresenceRateLimitReached
```

On the client side, the channel receives a `system` error message and is then closed. Any Presence, Broadcast, or Postgres Changes subscriptions on that channel stop until the client reconnects.

This error almost always means Presence is being used for high-frequency updates that it isn't designed for. This guide explains the limit, why it exists, and how to fix it.

## Why the error occurs

Each client has a per-connection limit on how often it can send Presence updates. By default, a client can send at most **5 Presence updates within a 30-second window**. Both `track()` and `untrack()` calls count toward this limit. When a client exceeds it, Realtime logs `ClientPresenceRateLimitReached` and shuts the channel down.

This is a per-client safeguard, and it is separate from the project-wide presence events per second limit (logged as `PresenceRateLimitReached`). A single client can hit `ClientPresenceRateLimitReached` on its own, even when overall project usage is low.

The limit exists because Presence syncs state through the server and notifies **every** subscriber on the channel on each change. A client that calls `track()` in a tight loop—for example, on every mouse move to share a cursor position—multiplies its updates across all subscribers and degrades the channel for everyone. The rate limit stops one client from doing this.

## How to fix it

The fix is to stop sending frequent Presence updates. Choose the option that matches your use case.

### Use Broadcast for high-frequency updates

Presence is meant for slow-changing state such as online/offline status, the document a user is viewing, or which page they're on. For high-frequency or fire-and-forget data—live cursors, typing indicators, pointer positions—use [Broadcast](/docs/guides/realtime/broadcast) instead. Broadcast relays messages through Realtime to connected clients without maintaining synced Presence state, so it handles rapid updates without triggering this limit.

### Throttle your Presence updates

If you do need Presence for state that changes often, throttle your `track()` and `untrack()` calls so the client sends at most a few Presence updates per window. Only update Presence when the shared state changes, and coalesce bursts into a single update rather than sending one on every event.

## How to prevent it

- Reserve Presence for slow-changing state, and reach for [Broadcast](/docs/guides/realtime/broadcast) for anything that updates rapidly. See the guidance in the [Presence guide](/docs/guides/realtime/presence).
- Call `track()` only when the shared state changes, not on a timer or on every input event.

## Related resources

- [Presence](/docs/guides/realtime/presence) — when to use Presence and how it works
- [Broadcast](/docs/guides/realtime/broadcast) — the right tool for high-frequency updates
- [Realtime limits](/docs/guides/realtime/limits) — other limits that apply to your project
Loading
Loading