mock other product pages

This commit is contained in:
Alan Daniel committed 2026-04-01 14:09:47 -04:00
1 parent 2c739fe8e9
commit 4efb47a6bb
30 files changed
+3160

No files matched your search

@@ -0,0 +1,280 @@
import { createHighlighter, type ThemeRegistration } from 'shiki'
import { ApiSectionClient } from './ApiSectionClient'
const supabaseDark: ThemeRegistration = {
name: 'supabase-dark',
type: 'dark',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#ffffff',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#bda4ff' } },
{ scope: ['entity.name.function', 'support.function', 'entity.name.tag', 'support.class.component'], settings: { foreground: '#3ecf8e' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#3ecf8e' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key', 'entity.other.attribute-name'], settings: { foreground: '#3ecf8e' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
],
}
const supabaseLight: ThemeRegistration = {
name: 'supabase-light',
type: 'light',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#525252',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#6b35dc' } },
{ scope: ['entity.name.function', 'support.function', 'entity.name.tag', 'support.class.component'], settings: { foreground: '#15593b' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#15593b' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key', 'entity.other.attribute-name'], settings: { foreground: '#15593b' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
],
}
const LANG_MAP: Record<string, string> = {
JavaScript: 'javascript',
Flutter: 'dart',
Python: 'python',
'C#': 'csharp',
Kotlin: 'kotlin',
Swift: 'swift',
}
const API_EXAMPLES = [
{
icon: 'UserPlus' as const,
title: 'Sign up',
description: 'Create new accounts with email/password, phone, or third-party providers.',
code: {
JavaScript: `// Sign up with email and password
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'secure-password',
})`,
Flutter: `// Sign up with email and password
final response = await supabase.auth.signUp(
email: 'user@example.com',
password: 'secure-password',
);`,
Python: `# Sign up with email and password
response = supabase.auth.sign_up({
"email": "user@example.com",
"password": "secure-password",
})`,
'C#': `// Sign up with email and password
var session = await supabase.Auth.SignUp(
"user@example.com",
"secure-password"
);`,
Kotlin: `// Sign up with email and password
supabase.auth.signUpWith(Email) {
email = "user@example.com"
password = "secure-password"
}`,
Swift: `// Sign up with email and password
try await supabase.auth.signUp(
email: "user@example.com",
password: "secure-password"
)`,
},
},
{
icon: 'LogIn' as const,
title: 'Sign in',
description: 'Authenticate users with multiple strategies in a single call.',
code: {
JavaScript: `// Sign in with email
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'secure-password',
})
// Sign in with magic link
const { data, error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
})`,
Flutter: `// Sign in with email
final response = await supabase.auth.signInWithPassword(
email: 'user@example.com',
password: 'secure-password',
);
// Sign in with magic link
await supabase.auth.signInWithOtp(
email: 'user@example.com',
);`,
Python: `# Sign in with email
response = supabase.auth.sign_in_with_password({
"email": "user@example.com",
"password": "secure-password",
})
# Sign in with magic link
response = supabase.auth.sign_in_with_otp({
"email": "user@example.com",
})`,
'C#': `// Sign in with email
var session = await supabase.Auth.SignIn(
"user@example.com",
"secure-password"
);
// Sign in with magic link
await supabase.Auth.SignIn(
Constants.SignInType.MagicLink,
"user@example.com"
);`,
Kotlin: `// Sign in with email
supabase.auth.signInWith(Email) {
email = "user@example.com"
password = "secure-password"
}
// Sign in with magic link
supabase.auth.signInWith(OTP) {
email = "user@example.com"
}`,
Swift: `// Sign in with email
try await supabase.auth.signIn(
email: "user@example.com",
password: "secure-password"
)
// Sign in with magic link
try await supabase.auth.signInWithOTP(
email: "user@example.com"
)`,
},
},
{
icon: 'Globe' as const,
title: 'OAuth logins',
description: 'Social login with 20+ providers and custom scopes for extended permissions.',
code: {
JavaScript: `// Sign in with GitHub
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
scopes: 'repo gist notifications',
},
})`,
Flutter: `// Sign in with GitHub
await supabase.auth.signInWithOAuth(
OAuthProvider.github,
scopes: 'repo gist notifications',
);`,
Python: `# Sign in with GitHub
response = supabase.auth.sign_in_with_oauth({
"provider": "github",
"options": {
"scopes": "repo gist notifications",
},
})`,
'C#': `// Sign in with GitHub
var session = await supabase.Auth.SignIn(
Constants.Provider.Github,
new SignInOptions {
Scopes = "repo gist notifications"
}
);`,
Kotlin: `// Sign in with GitHub
supabase.auth.signInWith(Github) {
scopes = "repo gist notifications"
}`,
Swift: `// Sign in with GitHub
try await supabase.auth.signInWithOAuth(
provider: .github,
scopes: "repo gist notifications"
)`,
},
},
{
icon: 'KeyRound' as const,
title: 'Session management',
description: 'Retrieve, refresh, and manage user sessions with built-in token handling.',
code: {
JavaScript: `// Get the current user
const { data: { user } } = await supabase.auth.getUser()
// Listen for auth state changes
supabase.auth.onAuthStateChange((event, session) => {
console.log(event, session)
})`,
Flutter: `// Get the current user
final user = supabase.auth.currentUser;
// Listen for auth state changes
supabase.auth.onAuthStateChange.listen((data) {
final event = data.event;
final session = data.session;
});`,
Python: `# Get the current user
user = supabase.auth.get_user()
# Get the current session
session = supabase.auth.get_session()`,
'C#': `// Get the current user
var user = supabase.Auth.CurrentUser;
// Listen for auth state changes
supabase.Auth.AddStateChangedListener(
(sender, changed) => {
// Handle state change
}
);`,
Kotlin: `// Get the current user
val user = supabase.auth.currentUserOrNull()
// Listen for auth state changes
supabase.auth.sessionStatus.collect { status ->
when (status) {
is SessionStatus.Authenticated -> { }
SessionStatus.NotAuthenticated -> { }
}
}`,
Swift: `// Get the current user
let user = try await supabase.auth.user()
// Listen for auth state changes
for await (event, session) in supabase.auth.authStateChanges {
print(event, session)
}`,
},
},
]
export async function ApiSection() {
const hl = await createHighlighter({
themes: [supabaseDark, supabaseLight],
langs: Object.values(LANG_MAP),
})
const examples = API_EXAMPLES.map((example) => ({
icon: example.icon,
title: example.title,
description: example.description,
languages: Object.fromEntries(
Object.entries(example.code).map(([lang, code]) => [
lang,
{
darkHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-dark' }),
lightHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-light' }),
},
])
),
}))
hl.dispose()
return <ApiSectionClient examples={examples} />
}
@@ -0,0 +1,185 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown, Globe, KeyRound, LogIn, UserPlus } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { cn } from 'ui'
const ICONS = { UserPlus, LogIn, Globe, KeyRound } as const
type IconName = keyof typeof ICONS
type ApiExample = {
icon: IconName
title: string
description: string
languages: Record<string, { darkHtml: string; lightHtml: string }>
}
const LANGUAGES = ['JavaScript', 'Flutter', 'Python', 'C#', 'Kotlin', 'Swift']
const INTERVAL_DURATION = 8000
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
const [activeIdx, setActiveIdx] = useState(0)
const [activeLang, setActiveLang] = useState('JavaScript')
const [progress, setProgress] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
const active = examples[activeIdx]
const clearTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startTimer = useCallback(() => {
clearTimer()
setProgress(0)
const updateFrequency = 30
const increment = (100 / INTERVAL_DURATION) * updateFrequency
intervalRef.current = setInterval(() => {
setProgress((prev) => {
const next = prev + increment
if (next >= 100) return 100
return next
})
}, updateFrequency)
}, [clearTimer])
useEffect(() => {
if (progress >= 100) {
setActiveIdx((prev) => (prev + 1) % examples.length)
}
}, [progress, examples.length])
useEffect(() => {
if (inView) {
startTimer()
} else {
clearTimer()
}
return clearTimer
}, [inView, activeIdx, startTimer, clearTimer])
const handleTabClick = (index: number) => {
setActiveIdx(index)
startTimer()
}
return (
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Simple APIs
<br />
<span className="text-foreground">powerful libraries</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
APIs that you can understand. With powerful libraries that work on client and server-side
applications.
</p>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: tab cards */}
<div className="flex flex-col gap-1 items-start justify-end">
{examples.map((example, index) => {
const isActive = index === activeIdx
const Icon = ICONS[example.icon]
return (
<button
key={example.title}
onClick={() => handleTabClick(index)}
className={cn(
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
isActive
? 'text-foreground'
: 'text-foreground-muted hover:text-foreground-light'
)}
>
<Icon size={22} strokeWidth={1.5} />
{example.title}
</button>
)
})}
</div>
{/* Right: code block */}
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
{/* Language switcher */}
<div className="absolute top-3 right-4 z-10 flex items-center">
<div className="relative flex items-center gap-1 text-foreground-muted text-xs">
<select
value={activeLang}
onChange={(e) => setActiveLang(e.target.value)}
className="appearance-none bg-transparent text-foreground-light text-xs pr-4 cursor-pointer border-none outline-none shadow-none focus:border-none focus:outline-none focus:shadow-none focus:ring-0"
>
{LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
<ChevronDown size={12} className="absolute right-0 pointer-events-none" />
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${active.title}-${activeLang}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
exit={{ opacity: 0, transition: { duration: 0.05 } }}
>
<div
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
<div
dangerouslySetInnerHTML={{
__html: active.languages[activeLang]?.lightHtml ?? '',
}}
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
</motion.div>
</AnimatePresence>
<Link
href="/docs/guides/auth"
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
>
Documentation
<svg
width={12}
height={12}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
>
<path
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
import { CTASection } from '~/app/(home)/_components/CTASection'
import { FeaturesSection } from './FeaturesSection'
import { Hero } from './Hero'
import { HighlightsSection } from './HighlightsSection'
import { QuoteSection } from './QuoteSection'
export function AuthContent({
apiSlot,
rlsSlot,
}: {
apiSlot: React.ReactNode
rlsSlot: React.ReactNode
}) {
return (
<div className="overflow-x-clip">
<section>
<Hero />
</section>
<section className="border-t border-border">
<FeaturesSection />
</section>
<section className="border-t border-border">
<QuoteSection />
</section>
<section className="border-t border-border">
<HighlightsSection />
</section>
<section className="border-t border-border">
{apiSlot}
</section>
<section className="border-t border-border">
{rlsSlot}
</section>
<section className="border-t border-border">
<BuiltWithSupabaseSection />
</section>
<section className="border-t border-border">
<CTASection />
</section>
</div>
)
}
@@ -0,0 +1,493 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { Link as LinkIcon, Shield } from 'lucide-react'
import Image from 'next/image'
import { useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
const PROVIDERS = [
'github',
'google',
'apple',
'discord',
'facebook',
'gitlab',
'microsoft',
'twitter',
'twitch',
'spotify',
'slack',
'bitbucket',
'twilio',
'solana',
'ethereum',
]
const FEATURES = [
{
title: 'All the social providers',
description:
'Enable social logins with the click of a button. Google, Facebook, GitHub, Azure, and many more.',
visual: ProvidersSkeleton,
},
{
title: 'Fully integrated',
description:
'Built-in Authentication, Authorization, and User Management — no external services needed.',
visual: IntegratedSkeleton,
},
{
title: 'Own your data',
description:
'User data stored in your Supabase database. No third-party privacy concerns. Host in 16+ locations.',
visual: DataOwnershipSkeleton,
},
]
const COLS = 13
const ROWS = 13
const CELL_SIZE = 48
const GAP = 10
function ProvidersSkeleton() {
const [activeCell, setActiveCell] = useState(Math.floor((COLS * ROWS) / 2))
const prevCell = useRef(activeCell)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
const totalCells = COLS * ROWS
const grid = Array.from({ length: totalCells }, (_, i) => PROVIDERS[i % PROVIDERS.length])
// Keep highlight away from edges so grid always overflows the card
const MARGIN = 3
const pickNext = () => {
const prev = prevCell.current
const prevCol = prev % COLS
const prevRow = Math.floor(prev / COLS)
let next = prev
const prevProvider = PROVIDERS[prev % PROVIDERS.length]
// Try until we get a cell 5+ tiles away and a different provider
for (let attempt = 0; attempt < 50; attempt++) {
const col = MARGIN + Math.floor(Math.random() * (COLS - MARGIN * 2))
const row = MARGIN + Math.floor(Math.random() * (ROWS - MARGIN * 2))
const candidate = row * COLS + col
const dist = Math.abs(col - prevCol) + Math.abs(row - prevRow)
if (dist >= 5 && PROVIDERS[candidate % PROVIDERS.length] !== prevProvider) {
next = candidate
break
}
}
prevCell.current = next
return next
}
// Compute offset to center the active cell in the viewport
const activeCol = activeCell % COLS
const activeRow = Math.floor(activeCell / COLS)
const centerCol = Math.floor(COLS / 2)
const centerRow = Math.floor(ROWS / 2)
const offsetX = (centerCol - activeCol) * (CELL_SIZE + GAP)
const offsetY = (centerRow - activeRow) * (CELL_SIZE + GAP)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
threshold: 0.3,
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!isInView) return
const timer = setInterval(() => setActiveCell(pickNext()), 5000)
return () => clearInterval(timer)
}, [isInView])
return (
<div
ref={containerRef}
className="flex items-center justify-center w-full h-full relative overflow-hidden"
style={{
maskImage: 'radial-gradient(ellipse 70% 70% at 50% 50%, black 30%, transparent 75%)',
WebkitMaskImage: 'radial-gradient(ellipse 70% 70% at 50% 50%, black 30%, transparent 75%)',
}}
>
<div
className="grid transition-transform duration-1000 ease-in-out"
style={{
gridTemplateColumns: `repeat(${COLS}, ${CELL_SIZE}px)`,
gridTemplateRows: `repeat(${ROWS}, ${CELL_SIZE}px)`,
gap: `${GAP}px`,
transform: `translate(${offsetX}px, ${offsetY}px)`,
}}
>
{grid.map((provider, i) => {
const isActive = i === activeCell
return (
<div
key={i}
className={cn(
'flex items-center justify-center w-12 h-12 rounded-lg border transition-all duration-1000',
isActive ? 'bg-surface-200 border-border scale-110' : 'bg-surface-75 border-border'
)}
>
<Image
src={`/images/product/auth/${provider}-icon.svg`}
alt={provider}
width={20}
height={20}
className={cn(
'transition-all duration-1000',
isActive ? 'opacity-100 grayscale-0' : 'opacity-40 grayscale'
)}
/>
</div>
)
})}
</div>
</div>
)
}
const AUTH_STEPS = [
{ label: 'Sign up' },
{ label: 'Verify email' },
{ label: 'Create session' },
{ label: 'Access granted' },
]
function SignUpPreview() {
return (
<div className="flex flex-col gap-3">
<div className="text-foreground text-sm font-medium">Create an account</div>
<div className="flex flex-col gap-2">
<div className="h-8 rounded border border-border bg-surface-200 px-3 flex items-center">
<span className="text-foreground-muted text-xs">email@example.com</span>
</div>
<div className="h-8 rounded border border-border bg-surface-200 px-3 flex items-center">
<span className="text-foreground-muted text-xs">Password</span>
</div>
</div>
<div className="h-8 rounded bg-brand flex items-center justify-center">
<span className="text-xs font-medium text-background">Sign up</span>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-px bg-border" />
<span className="text-foreground-muted text-[10px]">or</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex gap-2">
<div className="flex-1 h-8 rounded border border-border bg-surface-200 flex items-center justify-center">
<span className="text-foreground-muted text-[10px]">Google</span>
</div>
<div className="flex-1 h-8 rounded border border-border bg-surface-200 flex items-center justify-center">
<span className="text-foreground-muted text-[10px]">GitHub</span>
</div>
</div>
</div>
)
}
function VerifyEmailPreview() {
return (
<div className="flex flex-col gap-3 items-center justify-center h-full">
<div className="w-10 h-10 rounded-full bg-brand/10 flex items-center justify-center">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#3ECF8E"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 7l-10 6L2 7" />
</svg>
</div>
<div className="text-foreground text-sm font-medium">Check your email</div>
<div className="text-foreground-muted text-xs text-center leading-relaxed">
We sent a verification link to
<br />
<span className="text-foreground">email@example.com</span>
</div>
<div className="flex gap-1.5 mt-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="w-7 h-8 rounded border border-border bg-surface-200" />
))}
</div>
</div>
)
}
function SessionPreview() {
return (
<div className="flex flex-col gap-3 justify-center h-full">
<div className="text-foreground-muted text-[10px] font-mono">Session</div>
<div className="flex flex-col gap-2">
{[
{ key: 'access_token', val: 'eyJhbG...kpXVc' },
{ key: 'token_type', val: 'bearer' },
{ key: 'expires_in', val: '3600' },
{ key: 'user.id', val: 'a1b2c3d4-...' },
{ key: 'user.email', val: 'email@example.com' },
{ key: 'user.role', val: 'authenticated' },
].map((row) => (
<div key={row.key} className="flex items-center justify-between">
<span className="text-[#6b35dc] dark:text-[#bda4ff] text-[10px] font-mono">
{row.key}
</span>
<span className="text-foreground-muted text-[10px] font-mono truncate ml-2">
{row.val}
</span>
</div>
))}
</div>
</div>
)
}
function AccessGrantedPreview() {
return (
<div className="flex flex-col gap-3 items-center justify-center h-full">
<div className="w-10 h-10 rounded-full bg-brand/10 flex items-center justify-center">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#3ECF8E"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
<div className="text-foreground text-sm font-medium">Welcome back</div>
<div className="text-foreground-muted text-xs text-center">You&apos;re now signed in</div>
<div className="w-full flex flex-col gap-2 mt-2">
<div className="h-2.5 rounded bg-foreground-muted/10 w-full" />
<div className="h-2.5 rounded bg-foreground-muted/10 w-4/5" />
<div className="h-2.5 rounded bg-foreground-muted/10 w-3/5" />
<div className="h-2.5 rounded bg-foreground-muted/10 w-2/5" />
</div>
</div>
)
}
const STEP_PREVIEWS = [SignUpPreview, VerifyEmailPreview, SessionPreview, AccessGrantedPreview]
function IntegratedSkeleton() {
const [step, setStep] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
threshold: 0.3,
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!isInView) return
const timer = setInterval(() => setStep((s) => (s + 1) % AUTH_STEPS.length), 3000)
return () => clearInterval(timer)
}, [isInView])
const Preview = STEP_PREVIEWS[step]
return (
<div ref={containerRef} className="relative w-full h-full flex overflow-hidden">
{/* Left: steps */}
<div className="flex flex-col gap-3 pt-8 pl-6 shrink-0">
{AUTH_STEPS.map((s, i) => (
<div key={s.label} className="flex items-center gap-2">
<div
className={cn(
'w-1.5 h-1.5 rounded-full shrink-0 transition-colors duration-500',
i <= step ? 'bg-brand' : 'bg-foreground-muted/20'
)}
/>
<span
className={cn(
'text-sm transition-colors duration-500 whitespace-nowrap',
i <= step ? 'text-foreground' : 'text-foreground-muted'
)}
>
{s.label}
</span>
</div>
))}
</div>
{/* Right: shared card with animated content, overflows card edges */}
<div
className="absolute -right-16 -top-8 -bottom-8 flex items-center w-[55%]"
style={{
maskImage: 'radial-gradient(ellipse 80% 70% at 30% 50%, black 30%, transparent 75%)',
WebkitMaskImage: 'radial-gradient(ellipse 80% 70% at 30% 50%, black 30%, transparent 75%)',
}}
>
<div className="w-[280px] bg-surface-75 border border-border rounded-lg p-5 relative overflow-hidden">
{STEP_PREVIEWS.map((StepPreview, i) => (
<motion.div
key={i}
className={i === 0 ? 'relative' : 'absolute inset-0 p-5'}
initial={false}
animate={{
opacity: i === step ? 1 : 0,
scale: i === step ? 1 : 0.95,
filter: i === step ? 'blur(0px)' : 'blur(4px)',
}}
transition={{ duration: 0.5, ease: 'easeInOut' }}
style={{ pointerEvents: i === step ? 'auto' : 'none' }}
>
<StepPreview />
</motion.div>
))}
</div>
</div>
</div>
)
}
function DataOwnershipSkeleton() {
const [hovered, setHovered] = useState(false)
return (
<div
className="flex items-center justify-center w-full h-full relative"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
maskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
WebkitMaskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
}}
>
{/* Abstract construction lines */}
<svg
className="absolute -inset-16 w-[calc(100%+128px)] h-[calc(100%+128px)] text-foreground-muted/20"
viewBox="0 0 480 480"
fill="none"
>
<rect
x="90"
y="70"
width="300"
height="340"
rx="36"
stroke="currentColor"
strokeWidth="0.5"
/>
<rect
x="60"
y="40"
width="360"
height="400"
rx="48"
stroke="currentColor"
strokeWidth="0.5"
/>
<circle cx="240" cy="240" r="140" stroke="currentColor" strokeWidth="0.5" />
<circle cx="240" cy="240" r="100" stroke="currentColor" strokeWidth="0.5" />
<line x1="240" y1="0" x2="240" y2="480" stroke="currentColor" strokeWidth="0.5" />
<line x1="0" y1="240" x2="480" y2="240" stroke="currentColor" strokeWidth="0.5" />
</svg>
{/* Highlighted lines on hover */}
<svg
className="absolute -inset-16 w-[calc(100%+128px)] h-[calc(100%+128px)] transition-opacity duration-500"
style={{ opacity: hovered ? 0.35 : 0 }}
viewBox="0 0 480 480"
fill="none"
>
<circle cx="240" cy="240" r="140" stroke="#3ECF8E" strokeWidth="0.75" opacity="0.3" />
<rect
x="90"
y="70"
width="300"
height="340"
rx="36"
stroke="#3ECF8E"
strokeWidth="0.75"
opacity="0.4"
/>
</svg>
{/* Shield icon */}
<div className="relative">
<Shield
size={80}
strokeWidth={0.75}
className="transition-colors duration-500"
style={{
color: hovered ? '#3ECF8E' : 'hsl(var(--foreground-muted))',
opacity: hovered ? 1 : 0.6,
}}
/>
<div
className="absolute inset-0 flex items-center justify-center text-xs font-mono transition-colors duration-500"
style={{
color: hovered ? '#3ECF8E' : 'hsl(var(--foreground-muted))',
opacity: hovered ? 0.8 : 0.4,
}}
>
RLS
</div>
</div>
</div>
)
}
export function FeaturesSection() {
return (
<div className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Everything you need
<br />
<span className="text-foreground">for user authentication</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
Social logins, email/password, magic links, phone auth, and more — with enterprise-grade
security built on PostgreSQL&apos;s Row Level Security.
</p>
</div>
</div>
{/* 3-col grid */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{FEATURES.map((feature) => {
const Visual = feature.visual
return (
<div
key={feature.title}
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
>
<div className="relative flex items-center justify-center h-[320px]">
<Visual />
</div>
<div className="px-6 py-5 flex flex-col gap-1">
<h4 className="text-foreground text-sm font-medium">{feature.title}</h4>
<p className="text-foreground-lighter text-sm">{feature.description}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import Link from 'next/link'
import { Button } from 'ui'
export function Hero() {
return (
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<div className="flex flex-col gap-6 lg:gap-8">
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
<span className="block">Authentication</span>
<span className="text-foreground-lighter block">built in, not bolted on</span>
</h1>
<div className="flex items-center gap-2">
<Button asChild size="medium">
<Link href="https://supabase.com/dashboard">Start your project</Link>
</Button>
<Button asChild size="medium" type="default">
<Link href="/docs/guides/auth">Documentation</Link>
</Button>
</div>
</div>
<p className="text-foreground-lighter text-sm lg:text-base">
Every Supabase project comes with a complete user management system. Including
PostgreSQL&apos;s Row Level Security for fine-grained access control.
</p>
</div>
</div>
)
}
@@ -0,0 +1,71 @@
import { Check } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
const HIGHLIGHTS = [
{
title: 'Multi-Factor Authentication',
features: [
'Time-based one-time passwords (TOTP)',
'Works with any authenticator app',
'Enforce MFA for specific user roles',
'Built-in enrollment and challenge flows',
],
cta: { label: 'Learn more', href: '/docs/guides/auth/auth-mfa' },
},
{
title: 'Enterprise SSO',
features: [
'SAML 2.0 support for identity providers',
'Connect to Azure AD, Okta, and more',
'Automatic user provisioning on sign-in',
'Manage SSO from the Dashboard or API',
],
cta: { label: 'Learn more', href: '/docs/guides/auth/enterprise-sso/auth-sso-saml' },
},
]
export function HighlightsSection() {
return (
<div className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Enterprise ready
<br />
<span className="text-foreground">security at every level</span>
</h3>
</div>
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{HIGHLIGHTS.map((highlight) => (
<div
key={highlight.title}
className="relative flex flex-col overflow-hidden min-h-[400px] bg-surface-75 border border-border rounded-lg"
>
<div className="relative z-10 flex flex-col justify-between h-full p-6 md:p-8">
<div className="flex flex-col gap-4">
<h4 className="text-foreground text-lg font-medium">{highlight.title}</h4>
<ul className="flex flex-col text-foreground-lighter text-sm gap-1.5">
{highlight.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 mt-0.5 shrink-0 stroke-2 text-brand" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<div className="mt-6">
<Button type="default" size="small" asChild>
<Link href={highlight.cta.href}>{highlight.cta.label}</Link>
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,16 @@
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
export function QuoteSection() {
return (
<SharedQuoteSection
quote="Supabase is not only super easy to get started, but also provides all the backend solutions we require as we continue to grow."
highlight="The auth system just works out of the box."
author={{
name: 'Alfred Lua',
role: 'Cofounder of Pebblely',
image: '/images/blog/avatars/alfred-lua-pebblely.jpeg',
link: '/customers/pebblely',
}}
/>
)
}
@@ -0,0 +1,117 @@
import { createHighlighter, type ThemeRegistration } from 'shiki'
import { RLSSectionClient } from './RLSSectionClient'
const supabaseDark: ThemeRegistration = {
name: 'supabase-dark',
type: 'dark',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#ffffff',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#bda4ff' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#3ecf8e' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#3ecf8e' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
],
}
const supabaseLight: ThemeRegistration = {
name: 'supabase-light',
type: 'light',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#525252',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#6b35dc' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#15593b' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#15593b' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
],
}
const RLS_EXAMPLES = [
{
title: 'Allow read access',
description: 'Public profiles are viewable by everyone.',
code: `-- 1. Create table
create table profiles (
id serial primary key,
name text
);
-- 2. Enable RLS
alter table profiles enable row level security;
-- 3. Create Policy
create policy "Public profiles are viewable by everyone."
on profiles for select
using ( true );`,
},
{
title: 'Restrict updates',
description: 'Users can only update their own rows.',
code: `-- 1. Create table
create table profiles (
id serial primary key,
name text
);
-- 2. Enable RLS
alter table profiles enable row level security;
-- 3. Create Policy
create policy "Users can update their own profiles."
on profiles for update
using ( (select auth.uid()) = id );`,
},
{
title: 'Advanced rules',
description: 'Team members can update team details using joins.',
code: `create table teams (
id bigint primary key generated always as identity,
name text
);
create table members (
team_id bigint references teams,
user_id uuid references auth.users
);
alter table teams enable row level security;
create policy "Team members can update team details if they belong to the team"
on teams
for update using (
(select auth.uid()) in (
select user_id from members
where team_id = id
)
);`,
},
]
export async function RLSSection() {
const hl = await createHighlighter({
themes: [supabaseDark, supabaseLight],
langs: ['sql'],
})
const examples = RLS_EXAMPLES.map((example) => ({
title: example.title,
description: example.description,
darkHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-dark' }),
lightHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-light' }),
}))
hl.dispose()
return <RLSSectionClient examples={examples} />
}
@@ -0,0 +1,163 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { cn } from 'ui'
type RLSExample = {
title: string
description: string
darkHtml: string
lightHtml: string
}
const INTERVAL_DURATION = 10000
export function RLSSectionClient({ examples }: { examples: RLSExample[] }) {
const [activeIdx, setActiveIdx] = useState(0)
const [progress, setProgress] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
const active = examples[activeIdx]
const clearTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startTimer = useCallback(() => {
clearTimer()
setProgress(0)
const updateFrequency = 30
const increment = (100 / INTERVAL_DURATION) * updateFrequency
intervalRef.current = setInterval(() => {
setProgress((prev) => {
const next = prev + increment
if (next >= 100) return 100
return next
})
}, updateFrequency)
}, [clearTimer])
useEffect(() => {
if (progress >= 100) {
setActiveIdx((prev) => (prev + 1) % examples.length)
}
}, [progress, examples.length])
useEffect(() => {
if (inView) {
startTimer()
} else {
clearTimer()
}
return clearTimer
}, [inView, activeIdx, startTimer, clearTimer])
const handleTabClick = (index: number) => {
setActiveIdx(index)
startTimer()
}
return (
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
User permissions
<br />
<span className="text-foreground">without the middleware</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
Build authorization rules with Postgres&apos; Row Level Security — control who can
create, edit, and delete specific rows in your database. No additional servers required.
</p>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: tabs */}
<div className="flex flex-col gap-3 items-start justify-end">
{examples.map((example, index) => {
const isActive = index === activeIdx
return (
<button
key={example.title}
onClick={() => handleTabClick(index)}
className={cn(
'text-left flex flex-col gap-0.5 py-2 transition-colors',
isActive
? 'text-foreground'
: 'text-foreground-muted hover:text-foreground-light'
)}
>
<span className="text-2xl font-medium">{example.title}</span>
<span
className={cn(
'text-sm transition-colors',
isActive ? 'text-foreground-lighter' : 'text-foreground-muted'
)}
>
{example.description}
</span>
</button>
)
})}
</div>
{/* Right: code block */}
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
<AnimatePresence mode="wait">
<motion.div
key={active.title}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
exit={{ opacity: 0, transition: { duration: 0.05 } }}
>
<div
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
<div
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
</motion.div>
</AnimatePresence>
<Link
href="/docs/guides/database/postgres/row-level-security"
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
>
Documentation
<svg
width={12}
height={12}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
>
<path
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</div>
</div>
</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
import { ApiSection } from './_components/ApiSection'
import { AuthContent } from './_components/AuthContent'
import { RLSSection } from './_components/RLSSection'
export const metadata: Metadata = {
title: 'Auth | Supabase',
description:
'Add user sign ups and logins to your app. Secure your data with Row Level Security. Works with all major OAuth providers.',
}
export default function AuthPage() {
return <AuthContent apiSlot={<ApiSection />} rlsSlot={<RLSSection />} />
}
@@ -0,0 +1,125 @@
import { createHighlighter, type ThemeRegistration } from 'shiki'
import { ApiSectionClient } from './ApiSectionClient'
const supabaseDark: ThemeRegistration = {
name: 'supabase-dark',
type: 'dark',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#ffffff',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#bda4ff' } },
{ scope: ['entity.name.function', 'support.function', 'entity.name.tag', 'support.class.component'], settings: { foreground: '#3ecf8e' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#3ecf8e' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key', 'entity.other.attribute-name'], settings: { foreground: '#3ecf8e' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
],
}
const supabaseLight: ThemeRegistration = {
name: 'supabase-light',
type: 'light',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#525252',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#6b35dc' } },
{ scope: ['entity.name.function', 'support.function', 'entity.name.tag', 'support.class.component'], settings: { foreground: '#15593b' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#15593b' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key', 'entity.other.attribute-name'], settings: { foreground: '#15593b' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
],
}
const API_EXAMPLES = [
{
icon: 'Database' as const,
title: 'Database changes',
description: 'Listen to inserts, updates, and deletes in real time.',
code: `import { createClient } from '@supabase/supabase-js'
const supabase = createClient('URL', 'ANON_KEY')
const channel = supabase
.channel('db-changes')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
}, (payload) => {
console.log('New message:', payload.new)
})
.subscribe()`,
},
{
icon: 'Users' as const,
title: 'Presence',
description: 'Track and synchronize online user state across clients.',
code: `import { createClient } from '@supabase/supabase-js'
const supabase = createClient('URL', 'ANON_KEY')
const channel = supabase.channel('online-users')
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Online users:', state)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ user: 'user-123' })
}
})`,
},
{
icon: 'Radio' as const,
title: 'Broadcast',
description: 'Send ephemeral messages to all subscribed clients instantly.',
code: `import { createClient } from '@supabase/supabase-js'
const supabase = createClient('URL', 'ANON_KEY')
const channel = supabase.channel('cursor-pos')
channel
.on('broadcast', { event: 'cursor' }, (payload) => {
console.log('Cursor position:', payload)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.send({
type: 'broadcast',
event: 'cursor',
payload: { x: 100, y: 200 },
})
}
})`,
},
]
export async function ApiSection() {
const hl = await createHighlighter({
themes: [supabaseDark, supabaseLight],
langs: ['javascript'],
})
const examples = API_EXAMPLES.map((example) => ({
icon: example.icon,
title: example.title,
description: example.description,
darkHtml: hl.codeToHtml(example.code, { lang: 'javascript', theme: 'supabase-dark' }),
lightHtml: hl.codeToHtml(example.code, { lang: 'javascript', theme: 'supabase-light' }),
}))
hl.dispose()
return <ApiSectionClient examples={examples} />
}
@@ -0,0 +1,163 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { Database, Radio, Users } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { cn } from 'ui'
const ICONS = { Database, Users, Radio } as const
type IconName = keyof typeof ICONS
type ApiExample = {
icon: IconName
title: string
description: string
darkHtml: string
lightHtml: string
}
const INTERVAL_DURATION = 10000
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
const [activeIdx, setActiveIdx] = useState(0)
const [progress, setProgress] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
const active = examples[activeIdx]
const clearTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startTimer = useCallback(() => {
clearTimer()
setProgress(0)
const updateFrequency = 30
const increment = (100 / INTERVAL_DURATION) * updateFrequency
intervalRef.current = setInterval(() => {
setProgress((prev) => {
const next = prev + increment
if (next >= 100) return 100
return next
})
}, updateFrequency)
}, [clearTimer])
useEffect(() => {
if (progress >= 100) {
setActiveIdx((prev) => (prev + 1) % examples.length)
}
}, [progress, examples.length])
useEffect(() => {
if (inView) {
startTimer()
} else {
clearTimer()
}
return clearTimer
}, [inView, activeIdx, startTimer, clearTimer])
const handleTabClick = (index: number) => {
setActiveIdx(index)
startTimer()
}
return (
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Simple and convenient
<br />
<span className="text-foreground">APIs</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
APIs that you can understand. With powerful libraries that work on client and server-side
applications.
</p>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: tab cards */}
<div className="flex flex-col gap-1 items-start justify-end">
{examples.map((example, index) => {
const isActive = index === activeIdx
const Icon = ICONS[example.icon]
return (
<button
key={example.title}
onClick={() => handleTabClick(index)}
className={cn(
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
isActive
? 'text-foreground'
: 'text-foreground-muted hover:text-foreground-light'
)}
>
<Icon size={22} strokeWidth={1.5} />
{example.title}
</button>
)
})}
</div>
{/* Right: code block */}
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
<AnimatePresence mode="wait">
<motion.div
key={active.title}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
exit={{ opacity: 0, transition: { duration: 0.05 } }}
>
<div
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
<div
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
</motion.div>
</AnimatePresence>
<Link
href="/docs/guides/realtime"
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
>
Documentation
<svg
width={12}
height={12}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
>
<path
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,308 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
const FEATURES = [
{
title: 'Database changes',
description:
'Listen to inserts, updates, and deletes in your Postgres database and react instantly.',
visual: DatabaseChangesSkeleton,
},
{
title: 'Presence',
description:
'Store and synchronize online user state consistently across all connected clients.',
visual: PresenceSkeleton,
},
{
title: 'Broadcast',
description: 'Send any data to any client subscribed to the same channel with low latency.',
visual: BroadcastSkeleton,
},
]
type ChangeEvent = { op: string; table: string; row: string }
const DB_EVENTS: ChangeEvent[] = [
{ op: 'INSERT', table: 'messages', row: "{ id: 42, text: 'Hello!' }" },
{ op: 'UPDATE', table: 'users', row: "{ id: 7, status: 'online' }" },
{ op: 'DELETE', table: 'messages', row: '{ id: 38 }' },
{ op: 'INSERT', table: 'orders', row: "{ id: 99, total: 49.00 }" },
{ op: 'UPDATE', table: 'messages', row: "{ id: 42, text: 'Updated' }" },
]
function DatabaseChangesSkeleton() {
const [eventIdx, setEventIdx] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
threshold: 0.3,
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!isInView) return
const timer = setInterval(() => setEventIdx((i) => (i + 1) % DB_EVENTS.length), 2500)
return () => clearInterval(timer)
}, [isInView])
const event = DB_EVENTS[eventIdx]
return (
<div ref={containerRef} className="relative w-full h-full flex flex-col items-center justify-center">
<div className="px-6 w-full max-w-[280px]">
<pre className="text-sm leading-relaxed font-mono overflow-hidden">
<span className="text-[#6b35dc] dark:text-[#bda4ff]">event</span>
{': '}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={event.op}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className={cn(
'inline-block',
event.op === 'INSERT' && 'text-[#15593b] dark:text-[#3ecf8e]',
event.op === 'UPDATE' && 'text-[#f1a10d] dark:text-[#ffcda1]',
event.op === 'DELETE' && 'text-[#e5484d] dark:text-[#F06A50]'
)}
>
{event.op}
</motion.span>
</AnimatePresence>
{'\n'}
<span className="text-[#6b35dc] dark:text-[#bda4ff]">table</span>
{': '}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={event.table}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="inline-block text-foreground"
>
{event.table}
</motion.span>
</AnimatePresence>
{'\n'}
<span className="text-[#6b35dc] dark:text-[#bda4ff]">new</span>
{': '}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={event.row}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="inline-block text-foreground-lighter"
>
{event.row}
</motion.span>
</AnimatePresence>
</pre>
</div>
</div>
)
}
const USERS = [
{ name: 'Alice', color: '#3ECF8E' },
{ name: 'Bob', color: '#6c63ff' },
{ name: 'Carol', color: '#F06A50' },
{ name: 'Dave', color: '#ffcda1' },
]
function PresenceSkeleton() {
const [onlineCount, setOnlineCount] = useState(2)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
threshold: 0.3,
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!isInView) return
const timer = setInterval(() => {
setOnlineCount((c) => {
if (c >= USERS.length) return 1
return c + 1
})
}, 2000)
return () => clearInterval(timer)
}, [isInView])
return (
<div ref={containerRef} className="flex items-center justify-center w-full h-full">
<div className="flex flex-col gap-2 w-48">
{USERS.map((user, i) => {
const isOnline = i < onlineCount
return (
<div key={user.name} className="flex items-center gap-3 py-1">
<div
className={cn(
'w-2 h-2 rounded-full transition-colors duration-500',
isOnline ? 'bg-brand' : 'bg-foreground-muted/20'
)}
/>
<span
className={cn(
'text-sm transition-colors duration-500',
isOnline ? 'text-foreground' : 'text-foreground-muted'
)}
>
{user.name}
</span>
<span
className={cn(
'text-xs ml-auto transition-colors duration-500',
isOnline ? 'text-brand' : 'text-foreground-muted/40'
)}
>
{isOnline ? 'online' : 'offline'}
</span>
</div>
)
})}
</div>
</div>
)
}
function BroadcastSkeleton() {
const [flash, setFlash] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
threshold: 0.3,
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!isInView) return
const timer = setInterval(() => {
setFlash(true)
setTimeout(() => setFlash(false), 600)
}, 2000)
return () => clearInterval(timer)
}, [isInView])
const clients = ['Client A', 'Client B', 'Client C']
return (
<div
ref={containerRef}
className="flex items-center justify-center w-full h-full"
style={{
maskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
WebkitMaskImage:
'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
}}
>
<div className="flex flex-col items-center gap-6">
{/* Channel hub */}
<div
className={cn(
'px-4 py-2 rounded-lg border text-xs font-medium transition-all duration-300',
flash
? 'bg-brand/10 border-brand/30 text-brand'
: 'bg-surface-75 border-border text-foreground-muted'
)}
>
channel:room-1
</div>
{/* Lines to clients */}
<div className="flex items-start gap-8">
{clients.map((client) => (
<div key={client} className="flex flex-col items-center gap-2">
<div
className={cn(
'w-px h-8 transition-colors duration-300',
flash ? 'bg-brand/40' : 'bg-foreground-muted/20'
)}
/>
<div
className={cn(
'px-3 py-1.5 rounded border text-xs transition-all duration-500',
flash
? 'bg-surface-200 border-brand/20 text-foreground'
: 'bg-surface-75 border-border text-foreground-muted'
)}
>
{client}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export function FeaturesSection() {
return (
<div className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Three ways
<br />
<span className="text-foreground">to go realtime</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
Database changes, presence tracking, and broadcast messaging — everything you need to
build collaborative, real-time applications.
</p>
</div>
</div>
{/* 3-col grid */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{FEATURES.map((feature) => {
const Visual = feature.visual
return (
<div
key={feature.title}
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
>
<div className="relative flex items-center justify-center h-[320px]">
<Visual />
</div>
<div className="px-6 py-5 flex flex-col gap-1">
<h4 className="text-foreground text-sm font-medium">{feature.title}</h4>
<p className="text-foreground-lighter text-sm">{feature.description}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import Link from 'next/link'
import { Button } from 'ui'
export function Hero() {
return (
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<div className="flex flex-col gap-6 lg:gap-8">
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
<span className="block">Realtime</span>
<span className="text-foreground-lighter block">sync in milliseconds</span>
</h1>
<div className="flex items-center gap-2">
<Button asChild size="medium">
<Link href="https://supabase.com/dashboard">Start your project</Link>
</Button>
<Button asChild size="medium" type="default">
<Link href="/docs/guides/realtime">Documentation</Link>
</Button>
</div>
</div>
<p className="text-foreground-lighter text-sm lg:text-base">
Sync client state globally over WebSockets. Listen to database changes, store user
presence, and broadcast messages to all connected clients.
</p>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
export function QuoteSection() {
return (
<SharedQuoteSection
quote="Supabase takes out the mental effort from our back-end infrastructure so we can focus on our customers needs."
author={{
name: 'Aaron Sullivan',
role: 'Principal Software Engineer at Epsilon3',
image: '/images/blog/avatars/aaron-epsilon3.png',
link: '/customers/epsilon3',
}}
/>
)
}
@@ -0,0 +1,30 @@
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
import { CTASection } from '~/app/(home)/_components/CTASection'
import { FeaturesSection } from './FeaturesSection'
import { Hero } from './Hero'
import { QuoteSection } from './QuoteSection'
export function RealtimeContent({ apiSlot }: { apiSlot: React.ReactNode }) {
return (
<div className="overflow-x-clip">
<section>
<Hero />
</section>
<section className="border-t border-border">
<FeaturesSection />
</section>
<section className="border-t border-border">
<QuoteSection />
</section>
<section className="border-t border-border">
{apiSlot}
</section>
<section className="border-t border-border">
<BuiltWithSupabaseSection />
</section>
<section className="border-t border-border">
<CTASection />
</section>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
import { ApiSection } from './_components/ApiSection'
import { RealtimeContent } from './_components/RealtimeContent'
export const metadata: Metadata = {
title: 'Realtime | Supabase',
description:
'Sync client state globally over WebSockets. Listen to database changes, store user presence, and broadcast messages in real time.',
}
export default function RealtimePage() {
return <RealtimeContent apiSlot={<ApiSection />} />
}
@@ -0,0 +1,213 @@
import { createHighlighter, type ThemeRegistration } from 'shiki'
import { ApiSectionClient } from './ApiSectionClient'
const supabaseDark: ThemeRegistration = {
name: 'supabase-dark',
type: 'dark',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#ffffff',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#bda4ff' } },
{ scope: ['entity.name.function', 'support.function', 'entity.name.tag', 'support.class.component'], settings: { foreground: '#3ecf8e' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#3ecf8e' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key', 'entity.other.attribute-name'], settings: { foreground: '#3ecf8e' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
],
}
const supabaseLight: ThemeRegistration = {
name: 'supabase-light',
type: 'light',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#525252',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#6b35dc' } },
{ scope: ['entity.name.function', 'support.function', 'entity.name.tag', 'support.class.component'], settings: { foreground: '#15593b' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#15593b' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key', 'entity.other.attribute-name'], settings: { foreground: '#15593b' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
],
}
const LANG_MAP: Record<string, string> = {
JavaScript: 'javascript',
Flutter: 'dart',
Python: 'python',
'C#': 'csharp',
Kotlin: 'kotlin',
Swift: 'swift',
}
const API_EXAMPLES = [
{
icon: 'Upload' as const,
title: 'Upload a file',
description: 'Upload any file type to your storage buckets with a single call.',
code: {
JavaScript: `// Upload an image to the "avatars" bucket
const spaceCat = event.target.files[0]
const { data, error } = await supabase
.storage
.from('avatars')
.upload('space-cat.png', spaceCat)`,
Flutter: `// Upload an image to the "avatars" bucket
final file = File('space-cat.png');
await supabase
.storage
.from('avatars')
.upload('space-cat.png', file);`,
Python: `# Upload an image to the "avatars" bucket
with open("space-cat.png", "rb") as f:
supabase.storage.from_("avatars").upload(
"space-cat.png", f
)`,
'C#': `// Upload an image to the "avatars" bucket
var bytes = File.ReadAllBytes("space-cat.png");
await supabase.Storage
.From("avatars")
.Upload(bytes, "space-cat.png");`,
Kotlin: `// Upload an image to the "avatars" bucket
supabase.storage.from("avatars")
.upload("space-cat.png", file)`,
Swift: `// Upload an image to the "avatars" bucket
let data = try Data(contentsOf: fileURL)
try await supabase.storage
.from("avatars")
.upload("space-cat.png", data: data)`,
},
},
{
icon: 'Download' as const,
title: 'Download a file',
description: 'Retrieve files from your buckets directly or via signed URLs.',
code: {
JavaScript: `// Download the "space-cat.png" from "avatars"
const { data, error } = await supabase
.storage
.from('avatars')
.download('space-cat.png')`,
Flutter: `// Download the "space-cat.png" from "avatars"
final bytes = await supabase
.storage
.from('avatars')
.download('space-cat.png');`,
Python: `# Download the "space-cat.png" from "avatars"
data = supabase.storage.from_("avatars") \\
.download("space-cat.png")`,
'C#': `// Download the "space-cat.png" from "avatars"
var bytes = await supabase.Storage
.From("avatars")
.Download("space-cat.png");`,
Kotlin: `// Download the "space-cat.png" from "avatars"
val bytes = supabase.storage.from("avatars")
.downloadAuthenticated("space-cat.png")`,
Swift: `// Download the "space-cat.png" from "avatars"
let data = try await supabase.storage
.from("avatars")
.download(path: "space-cat.png")`,
},
},
{
icon: 'List' as const,
title: 'List files',
description: 'Browse and list all files within a storage bucket.',
code: {
JavaScript: `// List all the files in the "avatars" bucket
const { data, error } = await supabase
.storage
.from('avatars')
.list()`,
Flutter: `// List all the files in the "avatars" bucket
final files = await supabase
.storage
.from('avatars')
.list();`,
Python: `# List all the files in the "avatars" bucket
files = supabase.storage.from_("avatars") \\
.list()`,
'C#': `// List all the files in the "avatars" bucket
var files = await supabase.Storage
.From("avatars")
.List();`,
Kotlin: `// List all the files in the "avatars" bucket
val files = supabase.storage.from("avatars")
.list()`,
Swift: `// List all the files in the "avatars" bucket
let files = try await supabase.storage
.from("avatars")
.list()`,
},
},
{
icon: 'ArrowRightLeft' as const,
title: 'Move and rename',
description: 'Organize your files by moving and renaming within or across buckets.',
code: {
JavaScript: `// Move and rename files
const { data, error } = await supabase
.storage
.from('avatars')
.move('public/space-cat.png', 'private/space-cat.png')`,
Flutter: `// Move and rename files
await supabase
.storage
.from('avatars')
.move('public/space-cat.png', 'private/space-cat.png');`,
Python: `# Move and rename files
supabase.storage.from_("avatars") \\
.move("public/space-cat.png", "private/space-cat.png")`,
'C#': `// Move and rename files
await supabase.Storage
.From("avatars")
.Move("public/space-cat.png", "private/space-cat.png");`,
Kotlin: `// Move and rename files
supabase.storage.from("avatars")
.move("public/space-cat.png", "private/space-cat.png")`,
Swift: `// Move and rename files
try await supabase.storage
.from("avatars")
.move(
from: "public/space-cat.png",
to: "private/space-cat.png"
)`,
},
},
]
export async function ApiSection() {
const hl = await createHighlighter({
themes: [supabaseDark, supabaseLight],
langs: Object.values(LANG_MAP),
})
const examples = API_EXAMPLES.map((example) => ({
icon: example.icon,
title: example.title,
description: example.description,
languages: Object.fromEntries(
Object.entries(example.code).map(([lang, code]) => [
lang,
{
darkHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-dark' }),
lightHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-light' }),
},
])
),
}))
hl.dispose()
return <ApiSectionClient examples={examples} />
}
@@ -0,0 +1,185 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { ArrowRightLeft, ChevronDown, Download, List, Upload } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { cn } from 'ui'
const ICONS = { Upload, Download, List, ArrowRightLeft } as const
type IconName = keyof typeof ICONS
type ApiExample = {
icon: IconName
title: string
description: string
languages: Record<string, { darkHtml: string; lightHtml: string }>
}
const LANGUAGES = ['JavaScript', 'Flutter', 'Python', 'C#', 'Kotlin', 'Swift']
const INTERVAL_DURATION = 8000
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
const [activeIdx, setActiveIdx] = useState(0)
const [activeLang, setActiveLang] = useState('JavaScript')
const [progress, setProgress] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
const active = examples[activeIdx]
const clearTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startTimer = useCallback(() => {
clearTimer()
setProgress(0)
const updateFrequency = 30
const increment = (100 / INTERVAL_DURATION) * updateFrequency
intervalRef.current = setInterval(() => {
setProgress((prev) => {
const next = prev + increment
if (next >= 100) return 100
return next
})
}, updateFrequency)
}, [clearTimer])
useEffect(() => {
if (progress >= 100) {
setActiveIdx((prev) => (prev + 1) % examples.length)
}
}, [progress, examples.length])
useEffect(() => {
if (inView) {
startTimer()
} else {
clearTimer()
}
return clearTimer
}, [inView, activeIdx, startTimer, clearTimer])
const handleTabClick = (index: number) => {
setActiveIdx(index)
startTimer()
}
return (
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Simple and convenient
<br />
<span className="text-foreground">APIs</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
Built from the ground up for interoperable authentication. Fast and easy to implement
using our powerful library clients.
</p>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: tab cards */}
<div className="flex flex-col gap-1 items-start justify-end">
{examples.map((example, index) => {
const isActive = index === activeIdx
const Icon = ICONS[example.icon]
return (
<button
key={example.title}
onClick={() => handleTabClick(index)}
className={cn(
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
isActive
? 'text-foreground'
: 'text-foreground-muted hover:text-foreground-light'
)}
>
<Icon size={22} strokeWidth={1.5} />
{example.title}
</button>
)
})}
</div>
{/* Right: code block */}
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
{/* Language switcher */}
<div className="absolute top-3 right-4 z-10 flex items-center">
<div className="relative flex items-center gap-1 text-foreground-muted text-xs">
<select
value={activeLang}
onChange={(e) => setActiveLang(e.target.value)}
className="appearance-none bg-transparent text-foreground-light text-xs pr-4 cursor-pointer border-none outline-none shadow-none focus:border-none focus:outline-none focus:shadow-none focus:ring-0"
>
{LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
<ChevronDown size={12} className="absolute right-0 pointer-events-none" />
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${active.title}-${activeLang}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
exit={{ opacity: 0, transition: { duration: 0.05 } }}
>
<div
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
<div
dangerouslySetInnerHTML={{
__html: active.languages[activeLang]?.lightHtml ?? '',
}}
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
</motion.div>
</AnimatePresence>
<Link
href="/docs/guides/storage"
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
>
Documentation
<svg
width={12}
height={12}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
>
<path
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,81 @@
import { Check } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
const BUCKET_TYPES = [
{
title: 'Files buckets',
features: [
'Store images, videos, documents, and more',
'Serve fast from a global CDN',
'Fine-grained access controls',
'Image optimizations and transformations',
],
cta: { label: 'Learn more', href: '/docs/guides/storage' },
},
{
title: 'Analytics buckets',
features: [
'Apache Iceberg open table format',
'Efficient query, partition, and transform',
'Historical and time-series data',
'Optionally expose via Postgres',
],
cta: { label: 'Learn more', href: '/docs/guides/storage/analytics-buckets' },
},
{
title: 'Vector buckets',
features: [
'Store and index vector embeddings',
'Multiple distance metrics',
'Metadata filtering and similarity queries',
'RAG systems and AI-powered search',
],
cta: { label: 'Learn more', href: '/docs/guides/storage/vector-buckets' },
},
]
export function BucketTypesSection() {
return (
<div className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Bucket types
<br />
<span className="text-foreground">for every application</span>
</h3>
</div>
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{BUCKET_TYPES.map((bucket) => (
<div
key={bucket.title}
className="relative flex flex-col overflow-hidden min-h-[320px] bg-surface-75 border border-border rounded-lg"
>
<div className="relative z-10 flex flex-col justify-between h-full p-6 md:p-8">
<div className="flex flex-col gap-4">
<h4 className="text-foreground text-lg font-medium">{bucket.title}</h4>
<ul className="flex flex-col text-foreground-lighter text-sm gap-1.5">
{bucket.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="w-3.5 h-3.5 mt-0.5 shrink-0 stroke-2 text-brand" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<div className="mt-6">
<Button type="default" size="small" asChild>
<Link href={bucket.cta.href}>{bucket.cta.label}</Link>
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,222 @@
'use client'
import { useState } from 'react'
import { cn } from 'ui'
const FEATURES = [
{
title: 'Interoperable',
description:
'Integrates with the rest of Supabase — Auth and Postgres work together seamlessly.',
visual: InteroperableSkeleton,
},
{
title: 'Lightning fast',
description:
'Thin API server layer that leverages Postgres permissions and delivers content via global CDN.',
visual: CDNSkeleton,
},
{
title: 'Multiple bucket types',
description:
'Files, Analytics, or Vector buckets — choose the right storage model for your application.',
visual: BucketsSkeleton,
},
]
function InteroperableSkeleton() {
const [hovered, setHovered] = useState(false)
const products = [
{ name: 'Storage', color: '#3ECF8E' },
{ name: 'Auth', color: '#6c63ff' },
{ name: 'Database', color: '#3ECF8E' },
]
return (
<div
className="flex items-center justify-center w-full h-full relative"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
maskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
WebkitMaskImage:
'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
}}
>
<div className="flex items-center gap-4">
{products.map((product, i) => (
<div key={product.name} className="flex items-center gap-4">
<div
className={cn(
'w-16 h-16 rounded-lg border flex items-center justify-center transition-all duration-500',
hovered ? 'bg-surface-200 border-brand/30' : 'bg-surface-75 border-border'
)}
>
<span
className="text-xs font-medium transition-colors duration-500"
style={{ color: hovered ? product.color : 'hsl(var(--foreground-muted))' }}
>
{product.name}
</span>
</div>
{i < products.length - 1 && (
<div
className={cn(
'w-8 h-px transition-colors duration-500',
hovered ? 'bg-brand/40' : 'bg-foreground-muted/20'
)}
/>
)}
</div>
))}
</div>
</div>
)
}
function CDNSkeleton() {
return (
<div
className="flex items-center justify-center w-full h-full relative"
style={{
maskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
WebkitMaskImage:
'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
}}
>
<svg viewBox="0 0 320 320" className="w-64 h-64" fill="none">
{/* Globe outline */}
<circle
cx="160"
cy="160"
r="120"
stroke="hsl(var(--foreground-muted))"
strokeWidth="0.5"
opacity="0.3"
/>
<ellipse
cx="160"
cy="160"
rx="60"
ry="120"
stroke="hsl(var(--foreground-muted))"
strokeWidth="0.5"
opacity="0.3"
/>
<line
x1="40"
y1="160"
x2="280"
y2="160"
stroke="hsl(var(--foreground-muted))"
strokeWidth="0.5"
opacity="0.3"
/>
<line
x1="160"
y1="40"
x2="160"
y2="280"
stroke="hsl(var(--foreground-muted))"
strokeWidth="0.5"
opacity="0.3"
/>
{/* CDN nodes */}
{[
{ cx: 160, cy: 60 },
{ cx: 260, cy: 130 },
{ cx: 240, cy: 230 },
{ cx: 80, cy: 230 },
{ cx: 60, cy: 130 },
].map((pos, i) => (
<g key={i}>
<line
x1="160"
y1="160"
x2={pos.cx}
y2={pos.cy}
stroke="#3ECF8E"
strokeWidth="0.5"
opacity="0.2"
strokeDasharray="4 4"
/>
<circle cx={pos.cx} cy={pos.cy} r="6" fill="#3ECF8E" opacity="0.3" />
<circle cx={pos.cx} cy={pos.cy} r="3" fill="#3ECF8E" opacity="0.6" />
</g>
))}
{/* Center node */}
<circle cx="160" cy="160" r="8" fill="#3ECF8E" opacity="0.4" />
<circle cx="160" cy="160" r="4" fill="#3ECF8E" />
</svg>
</div>
)
}
function BucketsSkeleton() {
const buckets = [
{ name: 'Files', label: 'images, videos, docs' },
{ name: 'Analytics', label: 'Iceberg, time-series' },
{ name: 'Vectors', label: 'embeddings, RAG' },
]
return (
<div className="flex items-center justify-center w-full h-full">
<div className="flex flex-col gap-2 w-52">
{buckets.map((bucket) => (
<div
key={bucket.name}
className="flex items-center justify-between px-4 py-2.5 rounded-lg border border-border bg-surface-75"
>
<span className="text-foreground text-xs font-medium">{bucket.name}</span>
<span className="text-foreground-muted text-[11px]">{bucket.label}</span>
</div>
))}
</div>
</div>
)
}
export function FeaturesSection() {
return (
<div className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Everything you need
<br />
<span className="text-foreground">for object storage</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
S3 compatible object storage with global CDN, image transformations, and fine-grained
access controls powered by Postgres RLS.
</p>
</div>
</div>
{/* 3-col grid */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{FEATURES.map((feature) => {
const Visual = feature.visual
return (
<div
key={feature.title}
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
>
<div className="relative flex items-center justify-center h-[320px]">
<Visual />
</div>
<div className="px-6 py-5 flex flex-col gap-1">
<h4 className="text-foreground text-sm font-medium">{feature.title}</h4>
<p className="text-foreground-lighter text-sm">{feature.description}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import Link from 'next/link'
import { Button } from 'ui'
export function Hero() {
return (
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<div className="flex flex-col gap-6 lg:gap-8">
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
<span className="block">Store and serve</span>
<span className="text-foreground-lighter block">any type of digital content</span>
</h1>
<div className="flex items-center gap-2">
<Button asChild size="medium">
<Link href="https://supabase.com/dashboard">Start your project</Link>
</Button>
<Button asChild size="medium" type="default">
<Link href="/docs/guides/storage">Documentation</Link>
</Button>
</div>
</div>
<p className="text-foreground-lighter text-sm lg:text-base">
An open source S3 Compatible Object Store with unlimited scalability, for any file type.
With custom policies and permissions that are familiar and easy to implement.
</p>
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import { createHighlighter, type ThemeRegistration } from 'shiki'
import { PermissionsSectionClient } from './PermissionsSectionClient'
const supabaseDark: ThemeRegistration = {
name: 'supabase-dark',
type: 'dark',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#ffffff',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#bda4ff' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#3ecf8e' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#3ecf8e' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
],
}
const supabaseLight: ThemeRegistration = {
name: 'supabase-light',
type: 'light',
colors: {
'editor.background': '#00000000',
'editor.foreground': '#525252',
},
tokenColors: [
{ scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#6b35dc' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#15593b' } },
{ scope: ['constant', 'variable.other.constant', 'support.constant'], settings: { foreground: '#15593b' } },
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
],
}
const PERMISSION_EXAMPLES = [
{
title: 'Public access to a bucket',
description: 'Allow any user access to all objects in a bucket.',
code: `create policy "Public Access"
on storage.objects for all
using ( bucket_id = 'avatars' );`,
},
{
title: 'Public access to a folder',
description: "Allow public CRUD access to a specific folder within a bucket.",
code: `create policy "Public access to a folder"
on storage.objects for all
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = 'public'
);`,
},
{
title: 'Authenticated access',
description: 'Restrict access to authenticated users for a specific folder.',
code: `create policy "Logged in access"
on storage.objects
for all using (
bucket_id = 'avatars'
and auth.role() = 'authenticated'
and (storage.foldername(name))[1] = 'authenticated'
);`,
},
{
title: 'Individual file access',
description: 'Grant a specific user access to a single file by UID.',
code: `create policy "Individual access"
on storage.objects for all
using (
bucket_id = 'avatars'
and name = 'folder/only_uid.jpg'
and (select auth.uid()) = 'd8c7bce9-cfeb-497b-bd61-e66ce2cbdaa2'
);`,
},
]
export async function PermissionsSection() {
const hl = await createHighlighter({
themes: [supabaseDark, supabaseLight],
langs: ['sql'],
})
const examples = PERMISSION_EXAMPLES.map((example) => ({
title: example.title,
description: example.description,
darkHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-dark' }),
lightHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-light' }),
}))
hl.dispose()
return <PermissionsSectionClient examples={examples} />
}
@@ -0,0 +1,163 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { cn } from 'ui'
type PermissionExample = {
title: string
description: string
darkHtml: string
lightHtml: string
}
const INTERVAL_DURATION = 10000
export function PermissionsSectionClient({ examples }: { examples: PermissionExample[] }) {
const [activeIdx, setActiveIdx] = useState(0)
const [progress, setProgress] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
const active = examples[activeIdx]
const clearTimer = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startTimer = useCallback(() => {
clearTimer()
setProgress(0)
const updateFrequency = 30
const increment = (100 / INTERVAL_DURATION) * updateFrequency
intervalRef.current = setInterval(() => {
setProgress((prev) => {
const next = prev + increment
if (next >= 100) return 100
return next
})
}, updateFrequency)
}, [clearTimer])
useEffect(() => {
if (progress >= 100) {
setActiveIdx((prev) => (prev + 1) % examples.length)
}
}, [progress, examples.length])
useEffect(() => {
if (inView) {
startTimer()
} else {
clearTimer()
}
return clearTimer
}, [inView, activeIdx, startTimer, clearTimer])
const handleTabClick = (index: number) => {
setActiveIdx(index)
startTimer()
}
return (
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
{/* Header */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
Integrates natively
<br />
<span className="text-foreground">with Supabase Auth</span>
</h3>
<p className="text-foreground-lighter text-sm lg:text-base">
Storage authorization is built around Postgres so that you can use any combination of
SQL, Postgres functions, and even your own metadata to write policies.
</p>
</div>
</div>
{/* Content */}
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: tabs */}
<div className="flex flex-col gap-3 items-start justify-end">
{examples.map((example, index) => {
const isActive = index === activeIdx
return (
<button
key={example.title}
onClick={() => handleTabClick(index)}
className={cn(
'text-left flex flex-col gap-0.5 py-2 transition-colors',
isActive
? 'text-foreground'
: 'text-foreground-muted hover:text-foreground-light'
)}
>
<span className="text-2xl font-medium">{example.title}</span>
<span
className={cn(
'text-sm transition-colors',
isActive ? 'text-foreground-lighter' : 'text-foreground-muted'
)}
>
{example.description}
</span>
</button>
)
})}
</div>
{/* Right: code block */}
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
<AnimatePresence mode="wait">
<motion.div
key={active.title}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
exit={{ opacity: 0, transition: { duration: 0.05 } }}
>
<div
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
<div
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
/>
</motion.div>
</AnimatePresence>
<Link
href="/docs/reference/javascript/storage-createbucket"
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
>
Documentation
<svg
width={12}
height={12}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
>
<path
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
export function QuoteSection() {
return (
<SharedQuoteSection
quote="Supabase is great because it has everything. I don't need a different solution for authentication, a different solution for database, or a different solution for storage."
author={{
name: 'Yasser Elsaid',
role: 'Founder of Chatbase',
image: '/images/blog/avatars/yasser-elsaid-chatbase.jpeg',
link: '/customers/chatbase',
}}
/>
)
}
@@ -0,0 +1,43 @@
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
import { CTASection } from '~/app/(home)/_components/CTASection'
import { BucketTypesSection } from './BucketTypesSection'
import { FeaturesSection } from './FeaturesSection'
import { Hero } from './Hero'
import { QuoteSection } from './QuoteSection'
export function StorageContent({
apiSlot,
permissionsSlot,
}: {
apiSlot: React.ReactNode
permissionsSlot: React.ReactNode
}) {
return (
<div className="overflow-x-clip">
<section>
<Hero />
</section>
<section className="border-t border-border">
<FeaturesSection />
</section>
<section className="border-t border-border">
<QuoteSection />
</section>
<section className="border-t border-border">
<BucketTypesSection />
</section>
<section className="border-t border-border">
{apiSlot}
</section>
<section className="border-t border-border">
{permissionsSlot}
</section>
<section className="border-t border-border">
<BuiltWithSupabaseSection />
</section>
<section className="border-t border-border">
<CTASection />
</section>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
import { ApiSection } from './_components/ApiSection'
import { PermissionsSection } from './_components/PermissionsSection'
import { StorageContent } from './_components/StorageContent'
export const metadata: Metadata = {
title: 'Storage | Supabase',
description:
'An open source S3 Compatible Object Store with unlimited scalability, for any file type.',
}
export default function StoragePage() {
return <StorageContent apiSlot={<ApiSection />} permissionsSlot={<PermissionsSection />} />
}
File renamed without changes.
File renamed without changes.
File renamed without changes.