diff --git a/apps/ui-library/app/Providers.tsx b/apps/ui-library/app/Providers.tsx
index bec0be4ea9c..5956f800c43 100644
--- a/apps/ui-library/app/Providers.tsx
+++ b/apps/ui-library/app/Providers.tsx
@@ -1,5 +1,6 @@
'use client'
+import { QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from 'common'
import { Provider as JotaiProvider } from 'jotai'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
@@ -8,19 +9,24 @@ import { TooltipProvider } from 'ui'
import { FrameworkProvider } from '@/context/framework-context'
import { MobileMenuProvider } from '@/context/mobile-menu-context'
+import { useRootQueryClient } from '@/lib/fetch/queryClient'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ const queryClient = useRootQueryClient()
+
return (
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
)
}
diff --git a/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts b/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts
index cffeeb72284..2076171390d 100644
--- a/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts
+++ b/apps/ui-library/app/api/registry/tanstack-db/_generators/list.ts
@@ -115,13 +115,17 @@ ${selectFields}
{item.${displayField} || 'Untitled'}
- ${secondaryInfo}${hasCreatedAt ? `
+
${secondaryInfo}${
+ hasCreatedAt
+ ? `
Created {new Date(item.created_at).toLocaleDateString()}
- ` : `
+ `
+ : `
{item.${primaryKey}.slice(0, 8)}...
- `}
+ `
+ }
diff --git a/apps/ui-library/components/tanstack-db-generator.tsx b/apps/ui-library/components/tanstack-db-generator.tsx
deleted file mode 100644
index b68f7d986c6..00000000000
--- a/apps/ui-library/components/tanstack-db-generator.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-'use client'
-
-import { Check, Copy, Loader2 } from 'lucide-react'
-import { useState } from 'react'
-import {
- Button,
- cn,
- copyToClipboard,
- Input_Shadcn_ as Input,
- Label_Shadcn_ as Label,
- Tabs_Shadcn_ as Tabs,
- TabsContent_Shadcn_ as TabsContent,
- TabsList_Shadcn_ as TabsList,
- TabsTrigger_Shadcn_ as TabsTrigger,
-} from 'ui'
-
-import { useLocalStorage } from './use-local-storage'
-
-type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
-
-const LOCAL_STORAGE_KEY = 'package-manager-tanstack-db'
-
-const getBaseUrl = () => {
- if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
- return 'https://supabase.com/ui'
- } else if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'preview') {
- return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
- } else {
- return 'http://localhost:3004/ui'
- }
-}
-
-export function TanstackDBGenerator() {
- const [projectRef, setProjectRef] = useState('')
- const [anonKey, setAnonKey] = useState('')
- const [isValidating, setIsValidating] = useState(false)
- const [validationError, setValidationError] = useState(null)
- const [isGenerated, setIsGenerated] = useState(false)
- const [hasCopied, setHasCopied] = useState(false)
- const [packageManager, setPackageManager] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
-
- const baseUrl = getBaseUrl()
- const apiUrl = `${baseUrl}${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/registry/tanstack-db?ref=${encodeURIComponent(projectRef)}&anonKey=${encodeURIComponent(anonKey)}`
-
- const commands: Record = {
- npm: `npx shadcn@latest add "${apiUrl}"`,
- pnpm: `pnpm dlx shadcn@latest add "${apiUrl}"`,
- yarn: `yarn dlx shadcn@latest add "${apiUrl}"`,
- bun: `bunx --bun shadcn@latest add "${apiUrl}"`,
- }
-
- const handleValidate = async () => {
- if (!projectRef.trim() || !anonKey.trim()) {
- setValidationError('Please enter both Project Ref and Anon Key')
- return
- }
-
- setIsValidating(true)
- setValidationError(null)
-
- try {
- // Test the connection by fetching the OpenAPI spec
- const response = await fetch(`https://${projectRef}.supabase.co/rest/v1/`, {
- headers: {
- apikey: anonKey,
- },
- })
-
- if (!response.ok) {
- throw new Error(`Invalid credentials or project not found (${response.status})`)
- }
-
- const data = await response.json()
-
- if (!data.definitions || Object.keys(data.definitions).length === 0) {
- setValidationError('No tables found in your database. Create some tables first.')
- return
- }
-
- setIsGenerated(true)
- } catch (error) {
- setValidationError(
- error instanceof Error ? error.message : 'Failed to connect to your Supabase project'
- )
- } finally {
- setIsValidating(false)
- }
- }
-
- const handleCopy = (command: string) => {
- copyToClipboard(command)
- setHasCopied(true)
- setTimeout(() => setHasCopied(false), 2000)
- }
-
- const handleReset = () => {
- setIsGenerated(false)
- setProjectRef('')
- setAnonKey('')
- setValidationError(null)
- }
-
- return (
-
- {!isGenerated ? (
- <>
-
-
-
-
setProjectRef(e.target.value)}
- className="font-mono"
- autoComplete="off"
- />
-
- Found in your project settings or URL (e.g., abcdefghijklmnop)
-
-
-
-
-
setAnonKey(e.target.value)}
- className="font-mono"
- type="password"
- autoComplete="off"
- />
-
- Found in your project's API settings
-
-
-
-
- {validationError && (
-
- {validationError}
-
- )}
-
-
- >
- ) : (
- <>
-
- Successfully connected to your Supabase project. Copy the command below to install the
- block.
-
-
-
-
-
-
- {(Object.keys(commands) as PackageManager[]).map((manager) => (
-
- {manager}
-
- ))}
-
-
- {(Object.keys(commands) as PackageManager[]).map((manager) => (
-
-
-
- $
- {commands[manager]}
-
-
-
-
- ))}
-
-
-
-
-
- >
- )}
-
- )
-}
diff --git a/apps/ui-library/components/tanstack-db-generator/ComboBox.tsx b/apps/ui-library/components/tanstack-db-generator/ComboBox.tsx
new file mode 100644
index 00000000000..b8e3d32a9a2
--- /dev/null
+++ b/apps/ui-library/components/tanstack-db-generator/ComboBox.tsx
@@ -0,0 +1,163 @@
+'use client'
+
+import noop from 'lodash/noop'
+import { Check, ChevronsUpDown } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
+import {
+ Button_Shadcn_ as Button,
+ cn,
+ Command_Shadcn_ as Command,
+ CommandGroup_Shadcn_ as CommandGroup,
+ CommandInput_Shadcn_ as CommandInput,
+ CommandItem_Shadcn_ as CommandItem,
+ CommandList_Shadcn_ as CommandList,
+ Popover_Shadcn_ as Popover,
+ PopoverContent_Shadcn_ as PopoverContent,
+ PopoverTrigger_Shadcn_ as PopoverTrigger,
+ ScrollArea,
+} from 'ui'
+import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
+import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'
+
+export interface ComboBoxOption {
+ id: string
+ value: string
+ displayName: string
+}
+
+export function ComboBox({
+ isLoading,
+ disabled,
+ name,
+ options,
+ selectedOption,
+ selectedDisplayName,
+ onSelectOption = noop,
+ className,
+ search = '',
+ hasNextPage = false,
+ isFetching = false,
+ isFetchingNextPage = false,
+ fetchNextPage,
+ setSearch = () => {},
+ useCommandSearch = true,
+}: {
+ isLoading: boolean
+ disabled?: boolean
+ name: string
+ options: Opt[]
+ selectedOption?: string
+ selectedDisplayName?: string
+ onSelectOption?: (newValue: string) => void
+ className?: string
+ search?: string
+ hasNextPage?: boolean
+ isFetching?: boolean
+ isFetchingNextPage?: boolean
+ fetchNextPage?: () => void
+ setSearch?: (value: string) => void
+ useCommandSearch?: boolean
+}) {
+ const [open, setOpen] = useState(false)
+
+ const scrollRootRef = useRef(null)
+ const [sentinelRef, entry] = useIntersectionObserver({
+ root: scrollRootRef.current,
+ threshold: 0,
+ rootMargin: '0px',
+ })
+
+ useEffect(() => {
+ if (!isLoading && !isFetching && !isFetchingNextPage && hasNextPage && entry?.isIntersecting) {
+ fetchNextPage?.()
+ }
+ }, [isLoading, isFetching, isFetchingNextPage, hasNextPage, entry?.isIntersecting, fetchNextPage])
+
+ return (
+ {
+ setOpen(value)
+ if (!value) setSearch('')
+ }}
+ >
+
+
+
+
+
+ setSearch('')}
+ />
+
+
+ {isLoading ? (
+
+
+
+
+ ) : (
+ <>
+ {search.length > 0 && options.length === 0 && (
+
+ No {name}s found based on your search
+
+ )}
+ 7 ? 'h-[210px]' : ''}>
+ {options.map((option) => (
+ {
+ setOpen(false)
+ onSelectOption(selectedValue)
+ }}
+ className="cursor-pointer"
+ >
+
+ {option.displayName}
+
+ ))}
+
+ {hasNextPage && }
+
+ >
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx b/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx
new file mode 100644
index 00000000000..1eae498d05b
--- /dev/null
+++ b/apps/ui-library/components/tanstack-db-generator/ProjectPicker.tsx
@@ -0,0 +1,180 @@
+'use client'
+
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
+import { useIsLoggedIn, useIsUserLoading, LOCAL_STORAGE_KEYS } from 'common'
+import { useEffect, useMemo, useState } from 'react'
+import { useDebounce } from '@/hooks/useDebounce'
+import { organizationsQueryOptions } from '@/lib/fetch/organizations'
+import { projectsInfiniteQueryOptions, type ProjectInfoInfinite } from '@/lib/fetch/projects'
+import { projectKeysQueryOptions } from '@/lib/fetch/projectApi'
+import { retrieve, storeOrRemoveNull } from '@/lib/storage'
+import { type Org, fromOrgProjectValue, toDisplayNameOrgProject, toOrgProjectValue } from './utils'
+import { ComboBox, type ComboBoxOption } from './ComboBox'
+
+interface ProjectPickerProps {
+ onProjectResolved: (data: { projectRef: string; anonKey: string } | null) => void
+}
+
+export function ProjectPicker({ onProjectResolved }: ProjectPickerProps) {
+ const isUserLoading = useIsUserLoading()
+ const isLoggedIn = useIsLoggedIn()
+
+ const [selectedOrg, setSelectedOrg] = useState(null)
+ const [selectedProject, setSelectedProject] = useState(null)
+
+ const [search, setSearch] = useState('')
+ const debouncedSearch = useDebounce(search, 500)
+
+ const {
+ data: organizations,
+ isPending: organizationsIsPending,
+ isError: organizationsIsError,
+ } = useQuery(organizationsQueryOptions({ enabled: isLoggedIn }))
+
+ const {
+ data: projectsData,
+ isPending: projectsIsPending,
+ isError: projectsIsError,
+ isFetching,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ } = useInfiniteQuery(
+ projectsInfiniteQueryOptions(
+ { search: search.length === 0 ? search : debouncedSearch },
+ { enabled: isLoggedIn }
+ )
+ )
+ const projects =
+ useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
+
+ const anyIsPending = organizationsIsPending || projectsIsPending
+ const anyIsError = organizationsIsError || projectsIsError
+
+ const isReady =
+ isLoggedIn && !isUserLoading && !anyIsPending && !anyIsError && projects?.length > 0
+
+ const formattedData: ComboBoxOption[] = useMemo(
+ () =>
+ !isReady
+ ? []
+ : (projects!
+ .map((project) => {
+ const organization = organizations!.find((org) => org.id === project.organization_id)!
+ return {
+ id: project.ref,
+ value: toOrgProjectValue(organization, project),
+ displayName: toDisplayNameOrgProject(organization, project),
+ }
+ })
+ .filter(Boolean) as ComboBoxOption[]),
+ [organizations, projects, isReady]
+ )
+
+ // Auto-select from localStorage or first project
+ useEffect(() => {
+ if (isReady && (!selectedOrg || !selectedProject)) {
+ const storedMaybeOrgId = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_ORG)
+ const storedMaybeProjectRef = retrieve('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT)
+
+ let storedOrg: Org | undefined
+ let storedProject: ProjectInfoInfinite | undefined
+ if (storedMaybeOrgId && storedMaybeProjectRef) {
+ storedOrg = organizations!.find((org) => org.id === Number(storedMaybeOrgId))
+ storedProject = projects!.find((project) => project.ref === storedMaybeProjectRef)
+ }
+
+ if (storedOrg && storedProject && storedProject.organization_id === storedOrg.id) {
+ setSelectedOrg(storedOrg)
+ setSelectedProject(storedProject)
+ } else if (projects!.length > 0) {
+ const firstProject = projects![0]
+ const matchingOrg = organizations!.find((org) => org.id === firstProject.organization_id)
+ if (matchingOrg) {
+ setSelectedOrg(matchingOrg)
+ setSelectedProject(firstProject)
+ }
+ }
+ }
+ }, [organizations, projects, selectedOrg, selectedProject, isReady])
+
+ function handleSelectOrgProject(optionValue: string) {
+ const [orgId, projectRef] = fromOrgProjectValue(optionValue)
+ if (!orgId || !projectRef) return
+
+ const org = organizations?.find((org) => org.id === orgId)
+ const project = projects?.find((project) => project.ref === projectRef)
+
+ if (org && project && project.organization_id === org.id) {
+ setSelectedOrg(org)
+ setSelectedProject(project)
+ storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_ORG, org.id.toString())
+ storeOrRemoveNull('local', LOCAL_STORAGE_KEYS.SAVED_PROJECT, project.ref)
+ }
+ }
+
+ // Fetch API keys for the selected project
+ const {
+ data: apiKeysData,
+ isPending: isApiKeysPending,
+ isError: isApiKeysError,
+ } = useQuery(
+ projectKeysQueryOptions(
+ { projectRef: selectedProject?.ref },
+ { enabled: isLoggedIn && !!selectedProject?.ref }
+ )
+ )
+
+ // Resolve the key and notify parent
+ useEffect(() => {
+ if (!selectedProject?.ref || isApiKeysPending || isApiKeysError || !apiKeysData) {
+ onProjectResolved(null)
+ return
+ }
+
+ const key =
+ apiKeysData.find((k) => k.type === 'publishable')?.api_key ??
+ apiKeysData.find((k) => k.type === 'legacy' && k.id === 'anon')?.api_key
+
+ if (key) {
+ onProjectResolved({ projectRef: selectedProject.ref, anonKey: key })
+ } else {
+ onProjectResolved(null)
+ }
+ }, [selectedProject?.ref, apiKeysData, isApiKeysPending, isApiKeysError, onProjectResolved])
+
+ return (
+
+
+ {isApiKeysPending && selectedProject && (
+
Loading API keys...
+ )}
+ {isApiKeysError && selectedProject && (
+
Failed to load API keys for this project.
+ )}
+
+ )
+}
diff --git a/apps/ui-library/components/tanstack-db-generator/index.tsx b/apps/ui-library/components/tanstack-db-generator/index.tsx
new file mode 100644
index 00000000000..586cfd06576
--- /dev/null
+++ b/apps/ui-library/components/tanstack-db-generator/index.tsx
@@ -0,0 +1,152 @@
+'use client'
+
+import { useIsLoggedIn, useIsUserLoading } from 'common'
+import { Check, Copy, Loader2 } from 'lucide-react'
+import { useCallback, useState } from 'react'
+import {
+ Button,
+ cn,
+ copyToClipboard,
+ Tabs_Shadcn_ as Tabs,
+ TabsContent_Shadcn_ as TabsContent,
+ TabsList_Shadcn_ as TabsList,
+ TabsTrigger_Shadcn_ as TabsTrigger,
+} from 'ui'
+
+import { useLocalStorage } from '../use-local-storage'
+import { ProjectPicker } from './ProjectPicker'
+
+type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
+
+const LOCAL_STORAGE_KEY = 'package-manager-tanstack-db'
+
+const getBaseUrl = () => {
+ if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
+ return 'https://supabase.com/ui'
+ } else if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'preview') {
+ return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
+ } else {
+ return 'http://localhost:3004/ui'
+ }
+}
+
+export function TanstackDBGenerator() {
+ const isUserLoading = useIsUserLoading()
+ const isLoggedIn = useIsLoggedIn()
+
+ const [resolvedProject, setResolvedProject] = useState<{
+ projectRef: string
+ anonKey: string
+ } | null>(null)
+ const [hasCopied, setHasCopied] = useState(false)
+ const [packageManager, setPackageManager] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
+
+ const handleProjectResolved = useCallback(
+ (data: { projectRef: string; anonKey: string } | null) => {
+ setResolvedProject(data)
+ },
+ []
+ )
+
+ const baseUrl = getBaseUrl()
+ const apiUrl = resolvedProject
+ ? `${baseUrl}${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/registry/tanstack-db?ref=${encodeURIComponent(resolvedProject.projectRef)}&anonKey=${encodeURIComponent(resolvedProject.anonKey)}`
+ : ''
+
+ const commands: Record = {
+ npm: `npx shadcn@latest add "${apiUrl}"`,
+ pnpm: `pnpm dlx shadcn@latest add "${apiUrl}"`,
+ yarn: `yarn dlx shadcn@latest add "${apiUrl}"`,
+ bun: `bunx --bun shadcn@latest add "${apiUrl}"`,
+ }
+
+ const handleCopy = (command: string) => {
+ copyToClipboard(command)
+ setHasCopied(true)
+ setTimeout(() => setHasCopied(false), 2000)
+ }
+
+ if (isUserLoading) {
+ return (
+
+
+ Loading...
+
+ )
+ }
+
+ if (!isLoggedIn) {
+ return (
+
+
+
+ Log in
+ {' '}
+ to your Supabase account to generate installation commands for your project.
+
+
+ )
+ }
+
+ return (
+
+
+
+ {resolvedProject && (
+ <>
+
+ Copy the command below to install the block.
+
+
+
+
+
+
+ {(Object.keys(commands) as PackageManager[]).map((manager) => (
+
+ {manager}
+
+ ))}
+
+
+ {(Object.keys(commands) as PackageManager[]).map((manager) => (
+
+
+
+ $
+ {commands[manager]}
+
+
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/ui-library/components/tanstack-db-generator/utils.ts b/apps/ui-library/components/tanstack-db-generator/utils.ts
new file mode 100644
index 00000000000..dcf03909a1b
--- /dev/null
+++ b/apps/ui-library/components/tanstack-db-generator/utils.ts
@@ -0,0 +1,55 @@
+import type { OrganizationsData } from '@/lib/fetch/organizations'
+import type { ProjectInfoInfinite } from '@/lib/fetch/projects'
+
+export type Org = OrganizationsData[number]
+
+function removeDoubleQuotes(str: string) {
+ return str.replaceAll('"', '')
+}
+
+function escapeDoubleQuotes(str: string) {
+ return str.replaceAll('"', '\\"')
+}
+
+function unescapeDoubleQuotes(str: string) {
+ /**
+ * Regex matches a backslash followed by a double quote, except when the
+ * backslash is escaped.
+ */
+ return str.replace(/(? = {
+ readonly [P in keyof T]: DeepReadonly
+}
+
+export function toDisplayNameOrgProject(
+ org: DeepReadonly,
+ project: DeepReadonly
+) {
+ return `${org.name} / ${project.name}`
+}
+
+export function toOrgProjectValue(
+ org: DeepReadonly,
+ project: DeepReadonly
+) {
+ return escapeDoubleQuotes(
+ // @ts-ignore -- problem in OpenAPI spec -- project has ref property
+ JSON.stringify([org.id, project.ref, removeDoubleQuotes(toDisplayNameOrgProject(org, project))])
+ )
+}
+
+export function fromOrgProjectValue(
+ maybeOrgProject: string
+): [number, string, string] | [null, null, null] {
+ try {
+ const data = JSON.parse(unescapeDoubleQuotes(maybeOrgProject))
+ if (!Array.isArray(data) || data.length !== 3) {
+ throw Error("Shape of parsed JSON doesn't match form of org and project value")
+ }
+ return data as [number, string, string]
+ } catch {
+ return [null, null, null]
+ }
+}
diff --git a/apps/ui-library/content/docs/nextjs/tanstack-db.mdx b/apps/ui-library/content/docs/nextjs/tanstack-db.mdx
index 9e5c546da1a..b5a836256ba 100644
--- a/apps/ui-library/content/docs/nextjs/tanstack-db.mdx
+++ b/apps/ui-library/content/docs/nextjs/tanstack-db.mdx
@@ -26,7 +26,7 @@ This block installs the following files:
| `lib/db.ts` | Supabase collections for type-safe CRUD operations |
| `lib/supabase/client.ts` | Browser Supabase client |
| `lib/supabase/server.ts` | Server-side Supabase client |
-| `lib/supabase/middleware.ts` | Middleware for session handling |
+| `lib/supabase/middleware.ts` | Middleware for session handling |
| `app/providers.tsx` | Provider component for TanStack DB |
## Getting started
diff --git a/apps/ui-library/hooks/useDebounce.ts b/apps/ui-library/hooks/useDebounce.ts
new file mode 100644
index 00000000000..a90c6efaa69
--- /dev/null
+++ b/apps/ui-library/hooks/useDebounce.ts
@@ -0,0 +1,18 @@
+import { useEffect, useState } from 'react'
+
+// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L239
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => {
+ clearTimeout(handler)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
diff --git a/apps/ui-library/hooks/useIntersectionObserver.ts b/apps/ui-library/hooks/useIntersectionObserver.ts
new file mode 100644
index 00000000000..9a6b3e7dd04
--- /dev/null
+++ b/apps/ui-library/hooks/useIntersectionObserver.ts
@@ -0,0 +1,39 @@
+import { RefCallback, useCallback, useRef, useState } from 'react'
+
+// https://github.com/uidotdev/usehooks/blob/945436df0037bc21133379a5e13f1bd73f1ffc36/index.js#L512
+export function useIntersectionObserver(
+ options: {
+ root?: Element | Document | null
+ rootMargin?: string
+ threshold?: number | number[]
+ } = {}
+): [RefCallback, IntersectionObserverEntry | null] {
+ const { threshold = 1, root = null, rootMargin = '0px' } = options
+ const [entry, setEntry] = useState(null)
+
+ const previousObserver = useRef(null)
+
+ const customRef = useCallback(
+ (node: T | null) => {
+ if (previousObserver.current) {
+ previousObserver.current.disconnect()
+ previousObserver.current = null
+ }
+
+ if (node?.nodeType === Node.ELEMENT_NODE) {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ setEntry(entry)
+ },
+ { threshold, root, rootMargin }
+ )
+
+ observer.observe(node)
+ previousObserver.current = observer
+ }
+ },
+ [threshold, root, rootMargin]
+ )
+
+ return [customRef, entry]
+}
diff --git a/apps/ui-library/lib/fetch/fetchWrappers.ts b/apps/ui-library/lib/fetch/fetchWrappers.ts
new file mode 100644
index 00000000000..00b7d6af083
--- /dev/null
+++ b/apps/ui-library/lib/fetch/fetchWrappers.ts
@@ -0,0 +1,44 @@
+import type { paths } from 'api-types'
+import createClient from 'openapi-fetch'
+
+import { API_URL } from '@/lib/constants'
+import { getAccessToken } from '@/lib/userAuth'
+
+const DEFAULT_HEADERS = {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+}
+
+const client = createClient({
+ baseUrl: API_URL.replace('/platform', ''),
+ referrerPolicy: 'no-referrer-when-downgrade',
+ headers: DEFAULT_HEADERS,
+})
+
+async function constructHeaders(headersInit?: HeadersInit | undefined) {
+ const requestId = crypto.randomUUID()
+ const headers = new Headers(headersInit)
+
+ headers.set('X-Request-Id', requestId)
+
+ if (!headers.has('Authorization')) {
+ const accessToken = await getAccessToken()
+ if (accessToken) {
+ headers.set('Authorization', `Bearer ${accessToken}`)
+ }
+ }
+ return headers
+}
+
+client.use({
+ async onRequest({ request }) {
+ const headers = await constructHeaders(request.headers)
+ headers.forEach((value, key) => {
+ request.headers.set(key, value)
+ })
+
+ return request
+ },
+})
+
+export const { GET: get, POST: post } = client
diff --git a/apps/ui-library/lib/fetch/organizations.ts b/apps/ui-library/lib/fetch/organizations.ts
new file mode 100644
index 00000000000..aded75bd187
--- /dev/null
+++ b/apps/ui-library/lib/fetch/organizations.ts
@@ -0,0 +1,27 @@
+import { queryOptions } from '@tanstack/react-query'
+
+import { get } from './fetchWrappers'
+
+const organizationKeys = {
+ list: () => ['organizations'] as const,
+}
+
+async function getOrganizations(signal?: AbortSignal) {
+ // The generated api response in api.d.ts is typed as OrganizationResponseV1
+ // but the actual response should be typed as OrganizationResponse.
+ const { data, error } = await get('/platform/organizations', { signal })
+ if (error) throw error
+ return data
+}
+
+export type OrganizationsData = Awaited>
+
+export const organizationsQueryOptions = (
+ { enabled = true }: { enabled?: boolean } = { enabled: true }
+) => {
+ return queryOptions({
+ queryKey: organizationKeys.list(),
+ queryFn: ({ signal }) => getOrganizations(signal),
+ enabled,
+ })
+}
diff --git a/apps/ui-library/lib/fetch/projectApi.ts b/apps/ui-library/lib/fetch/projectApi.ts
new file mode 100644
index 00000000000..4015522019a
--- /dev/null
+++ b/apps/ui-library/lib/fetch/projectApi.ts
@@ -0,0 +1,68 @@
+import { get } from './fetchWrappers'
+import { queryOptions } from '@tanstack/react-query'
+
+export interface ProjectApiVariables {
+ projectRef?: string
+}
+
+export type ProjectKeys = Awaited>
+
+export type ProjectSettings = Awaited>
+
+const projectApiKeys = {
+ api: (projectRef: string | undefined) => ['projects', projectRef, 'api'] as const,
+ settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const,
+}
+
+async function getProjectKeys({ projectRef }: ProjectApiVariables, signal?: AbortSignal) {
+ if (!projectRef) {
+ throw Error('projectRef is required')
+ }
+
+ const { data, error } = await get('/v1/projects/{ref}/api-keys', {
+ params: {
+ path: { ref: projectRef },
+ },
+ signal,
+ })
+ if (error) throw error
+ return data
+}
+
+async function getProjectSettings({ projectRef }: ProjectApiVariables, signal?: AbortSignal) {
+ if (!projectRef) {
+ throw Error('projectRef is required')
+ }
+
+ const { data, error } = await get('/platform/projects/{ref}/settings', {
+ params: {
+ path: { ref: projectRef },
+ },
+ signal,
+ })
+ if (error) throw error
+ return data
+}
+
+export const projectKeysQueryOptions = (
+ { projectRef }: ProjectApiVariables,
+ { enabled = true }: { enabled?: boolean } = { enabled: true }
+) => {
+ return queryOptions({
+ queryKey: projectApiKeys.api(projectRef),
+ queryFn: ({ signal }) => getProjectKeys({ projectRef }, signal),
+ enabled: enabled && typeof projectRef !== 'undefined',
+ })
+}
+
+export const projectSettingsQueryOptions = (
+ { projectRef }: ProjectApiVariables,
+ { enabled = true }: { enabled?: boolean } = { enabled: true }
+) => {
+ return queryOptions({
+ queryKey: projectApiKeys.settings(projectRef),
+ queryFn: ({ signal }) => getProjectSettings({ projectRef }, signal),
+ enabled: enabled && typeof projectRef !== 'undefined',
+ })
+}
+
diff --git a/apps/ui-library/lib/fetch/projects.ts b/apps/ui-library/lib/fetch/projects.ts
new file mode 100644
index 00000000000..ff2c035284f
--- /dev/null
+++ b/apps/ui-library/lib/fetch/projects.ts
@@ -0,0 +1,75 @@
+import { infiniteQueryOptions } from '@tanstack/react-query'
+import { components } from 'api-types'
+import { get } from './fetchWrappers'
+
+const DEFAULT_LIMIT = 10
+const projectKeys = {
+ listInfinite: (params?: {
+ limit: number
+ sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc'
+ search?: string
+ }) => ['all-projects-infinite', params].filter(Boolean),
+}
+
+interface GetProjectsInfiniteVariables {
+ limit?: number
+ sort?: 'name_asc' | 'name_desc' | 'created_asc' | 'created_desc'
+ search?: string
+ page?: number
+}
+
+export type ProjectInfoInfinite =
+ components['schemas']['ListProjectsPaginatedResponse']['projects'][number]
+
+async function getProjects(
+ {
+ limit = DEFAULT_LIMIT,
+ page = 0,
+ sort = 'name_asc',
+ search: _search = '',
+ }: GetProjectsInfiniteVariables,
+ signal?: AbortSignal,
+ headers?: Record
+) {
+ const offset = page * limit
+ const search = _search.length === 0 ? undefined : _search
+
+ const { data, error } = await get('/platform/projects', {
+ // @ts-ignore [Joshen] API type issue for Version 2 endpoints
+ params: { query: { limit, offset, sort, search } },
+ signal,
+ headers: { ...headers, Version: '2' },
+ })
+
+ if (error) throw error
+ return data as unknown as components['schemas']['ListProjectsPaginatedResponse']
+}
+
+export type ProjectsInfiniteData = Awaited>
+
+export const projectsInfiniteQueryOptions = (
+ { limit = DEFAULT_LIMIT, sort = 'name_asc', search }: GetProjectsInfiniteVariables,
+ { enabled = true }: { enabled?: boolean } = { enabled: true }
+) => {
+ return infiniteQueryOptions({
+ enabled,
+ queryKey: projectKeys.listInfinite({ limit, sort, search }),
+ queryFn: ({ signal, pageParam }) =>
+ getProjects({ limit, page: pageParam as number, sort, search }, signal),
+ initialPageParam: 0,
+ getNextPageParam(lastPage, pages) {
+ const page = pages.length
+ const currentTotalCount = page * limit
+ // @ts-ignore [Joshen] API type issue for Version 2 endpoints
+ const totalCount = lastPage.pagination.count
+
+ if (currentTotalCount >= totalCount) return undefined
+ return page
+ },
+ staleTime: 30 * 60 * 1000, // 30 minutes
+ })
+}
+
+export function isProjectPaused(project: { status: string } | null): boolean | undefined {
+ return !project ? undefined : project.status === 'INACTIVE'
+}
diff --git a/apps/ui-library/lib/fetch/queryClient.ts b/apps/ui-library/lib/fetch/queryClient.ts
new file mode 100644
index 00000000000..d28330e4b59
--- /dev/null
+++ b/apps/ui-library/lib/fetch/queryClient.ts
@@ -0,0 +1,46 @@
+import { QueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+
+let queryClient: QueryClient | undefined
+
+function getQueryClient() {
+ const _queryClient =
+ queryClient ??
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ retry: (failureCount, error) => {
+ // Don't retry on 404s
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ error.code === 404
+ ) {
+ return false
+ }
+
+ if (failureCount < 3) {
+ return true
+ }
+
+ return false
+ },
+ },
+ },
+ })
+
+ // For SSG and SSR always create a new queryClient
+ if (typeof window === 'undefined') return _queryClient
+
+ // Create the query client once in the client
+ if (!queryClient) queryClient = _queryClient
+ return queryClient
+}
+
+export function useRootQueryClient() {
+ const [_queryClient] = useState(() => getQueryClient())
+
+ return _queryClient
+}
diff --git a/apps/ui-library/lib/storage.ts b/apps/ui-library/lib/storage.ts
new file mode 100644
index 00000000000..dcefeb2effe
--- /dev/null
+++ b/apps/ui-library/lib/storage.ts
@@ -0,0 +1,44 @@
+import { LOCAL_STORAGE_KEYS } from 'common'
+
+type LocalStorageKey = (typeof LOCAL_STORAGE_KEYS)[keyof typeof LOCAL_STORAGE_KEYS]
+type StorageType = 'local' | 'session'
+
+function getStorage(storageType: StorageType) {
+ return storageType === 'local' ? window.localStorage : window.sessionStorage
+}
+
+export function store(storageType: StorageType, key: LocalStorageKey, value: string) {
+ if (typeof window === 'undefined') return
+ const storage = getStorage(storageType)
+
+ try {
+ storage.setItem(key as string, value)
+ } catch {
+ console.error(`Failed to set storage item with key "${key}"`)
+ }
+}
+
+export function retrieve(storageType: StorageType, key: LocalStorageKey) {
+ if (typeof window === 'undefined') return
+ const storage = getStorage(storageType)
+ return storage.getItem(key as string)
+}
+
+export function remove(storageType: StorageType, key: LocalStorageKey) {
+ if (typeof window === 'undefined') return
+ const storage = getStorage(storageType)
+ return storage.removeItem(key as string)
+}
+
+export function storeOrRemoveNull(
+ storageType: StorageType,
+ key: LocalStorageKey,
+ value: string | null | undefined
+) {
+ if (typeof window === 'undefined') return
+ if (value === null || value === undefined) {
+ remove(storageType, key)
+ } else {
+ store(storageType, key, value)
+ }
+}
diff --git a/apps/ui-library/lib/userAuth.ts b/apps/ui-library/lib/userAuth.ts
new file mode 100644
index 00000000000..12fec885fe7
--- /dev/null
+++ b/apps/ui-library/lib/userAuth.ts
@@ -0,0 +1,36 @@
+import { gotrueClient } from 'common'
+import { useEffect } from 'react'
+
+export const auth = gotrueClient
+
+export async function getAccessToken() {
+ // ignore if server-side
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ const {
+ data: { session },
+ error,
+ } = await auth.getSession()
+ if (error) {
+ throw error
+ }
+ return session?.access_token
+}
+
+export function useOnLogout(callback: () => void) {
+ useEffect(() => {
+ const {
+ data: {
+ subscription: { unsubscribe },
+ },
+ } = auth.onAuthStateChange((event) => {
+ if (event === 'SIGNED_OUT') {
+ callback()
+ }
+ })
+
+ return unsubscribe
+ }, [callback])
+}
diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json
index 68b5e0253b4..2ed3dac2c1c 100644
--- a/apps/ui-library/package.json
+++ b/apps/ui-library/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
- "dev": "next dev --port 3004",
+ "dev": "next dev --port 8082",
"build": "pnpm run content:build && pnpm run build:registry && pnpm run build:llms && next build --turbopack",
"build:registry": "rimraf -G public/r/* && tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && shadcn build public/r/registry.json && tsx scripts/clean-registry.ts",
"build:llms": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-llms-txt.ts",
@@ -50,6 +50,7 @@
"@supabase/postgrest-js": "catalog:",
"@supabase/supa-mdx-lint": "0.2.6-alpha",
"@tanstack/react-query": "^5.83.0",
+ "api-types": "workspace:*",
"@supabase/vue-blocks": "workspace:*",
"axios": "^1.12.0",
"class-variance-authority": "*",
diff --git a/apps/ui-library/types/fetch.ts b/apps/ui-library/types/fetch.ts
new file mode 100644
index 00000000000..776c7a1fa09
--- /dev/null
+++ b/apps/ui-library/types/fetch.ts
@@ -0,0 +1,5 @@
+export interface ResponseError {
+ code?: number
+ message: string
+ requestId?: string
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 627d2620ec9..c116dde4ac7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -167,7 +167,7 @@ importers:
version: 15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
next-contentlayer2:
specifier: 0.4.6
- version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
+ version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -679,7 +679,7 @@ importers:
version: 15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
next-contentlayer2:
specifier: 0.4.6
- version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
+ version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1381,6 +1381,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.83.0
version: 5.83.0(react@18.3.1)
+ api-types:
+ specifier: workspace:*
+ version: link:../../packages/api-types
axios:
specifier: ^1.12.0
version: 1.13.5
@@ -1419,7 +1422,7 @@ importers:
version: 15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
next-contentlayer2:
specifier: 0.4.6
- version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
+ version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -33175,7 +33178,7 @@ snapshots:
neo-async@2.6.2: {}
- next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1):
+ next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@15.5.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1):
dependencies:
'@contentlayer2/core': 0.4.3(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1)
'@contentlayer2/utils': 0.4.3