You've deployed self-hosted Supabase and everything looks good from the dashboard. But when you try to connect your React or Vue application, something feels off. The examples in Supabase's documentation reference supabase.co URLs, and suddenly you're second-guessing whether you've configured things correctly.
The good news: connecting frontend frameworks to self-hosted Supabase uses the same client libraries as Supabase Cloud. The differences are subtle but important—wrong URL configuration breaks authentication flows, SSR misconfigurations leak tokens, and CORS issues can leave you debugging for hours. This guide covers the specific configuration patterns for Next.js, Nuxt, and SvelteKit that work with self-hosted deployments.
Understanding Self-Hosted URLs
Before diving into framework-specific code, let's clarify the URLs you need. Self-hosted Supabase exposes its API through a single entry point—typically port 8000 for HTTP or 8443 for HTTPS when using the default Kong configuration.
# Your self-hosted Supabase API URL (used by client SDKs) SUPABASE_URL=https://supabase.yourdomain.com # Your anon key (safe for frontend code) SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
The SUPABASE_URL must be the externally-reachable address of your Kong API gateway. If you've set up a reverse proxy with custom domains, use that domain. Internal Docker network URLs like http://kong:8000 won't work from browser code—those are only valid inside your server infrastructure.
Finding your anon key depends on how you deployed. Check your .env file for ANON_KEY or generate new keys using the environment variables guide. Never use the SERVICE_ROLE_KEY in frontend code—it bypasses Row Level Security and should only exist server-side.
Next.js Configuration
Next.js is the most common choice for Supabase applications, and the Supabase team provides first-class support through @supabase/ssr. Here's how to configure it for self-hosted instances.
Environment Variables
Create a .env.local file at your project root:
# .env.local NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
The NEXT_PUBLIC_ prefix exposes these variables to browser code. This is intentional—your Supabase URL and anon key must be accessible client-side. The anon key is safe to expose because it only grants permissions allowed by your Row Level Security policies.
Installing Dependencies
npm install @supabase/supabase-js @supabase/ssr
Creating the Supabase Client
For Next.js App Router, create utility functions that work in both server and client contexts:
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Ignore errors in Server Components
}
},
},
}
)
}
Middleware for Session Refresh
Authentication sessions need refreshing. Create middleware that runs on every request:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Refresh session if expired
await supabase.auth.getUser()
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Self-Hosted Gotchas for Next.js
Cookie Domain Issues: If your frontend runs on app.yourdomain.com and Supabase runs on supabase.yourdomain.com, cookies may not be shared correctly. Ensure your custom domain setup uses the same root domain, or configure explicit cookie domains.
HTTPS Requirement: Production self-hosted Supabase must run over HTTPS. The Supabase client sets Secure cookie flags by default, which won't work over HTTP except on localhost.
Auth Redirects: When using OAuth providers, the redirect URL must point to your self-hosted instance, not supabase.co. See our OAuth providers guide for the correct redirect URI format.
Nuxt 3 Configuration
Nuxt 3 uses a module-based approach. The @nuxtjs/supabase module works with self-hosted instances—you just need to point it at your deployment.
Installing the Module
npm install @nuxtjs/supabase
Configuration
Update your nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/supabase'],
supabase: {
url: process.env.SUPABASE_URL,
key: process.env.SUPABASE_ANON_KEY,
redirectOptions: {
login: '/login',
callback: '/confirm',
exclude: ['/', '/about', '/pricing'],
},
},
runtimeConfig: {
public: {
supabaseUrl: process.env.SUPABASE_URL,
supabaseKey: process.env.SUPABASE_ANON_KEY,
},
},
})
Environment Variables
Create a .env file:
SUPABASE_URL=https://supabase.yourdomain.com SUPABASE_ANON_KEY=your-anon-key-here
Note that Nuxt doesn't require the NUXT_PUBLIC_ prefix when using runtimeConfig.public. The module handles this automatically.
Using the Composables
The Nuxt module provides composables that work in both client and server contexts:
<script setup lang="ts">
const client = useSupabaseClient()
const user = useSupabaseUser()
const { data: posts } = await useAsyncData('posts', async () => {
const { data } = await client.from('posts').select('*')
return data
})
</script>
<template>
<div>
<p v-if="user">Welcome, {{ user.email }}</p>
<ul>
<li v-for="post in posts" :key="post.id">{{ post.title }}</li>
</ul>
</div>
</template>
Server-Side Database Access
For server routes and API endpoints that need elevated privileges:
// server/api/admin/users.get.ts
import { serverSupabaseServiceRole } from '#supabase/server'
export default defineEventHandler(async (event) => {
// This uses the service role key - bypasses RLS
// Only use for admin operations
const client = await serverSupabaseServiceRole(event)
const { data, error } = await client
.from('profiles')
.select('*')
.limit(100)
if (error) throw createError({ statusCode: 500, message: error.message })
return data
})
For the service role to work, add it to your environment:
# .env (server-side only, never expose to client) SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
Nuxt-Specific Considerations
Server Rendering: The Nuxt module handles SSR automatically. User sessions are read from cookies and available in useSupabaseUser() on both client and server.
Redirect Configuration: The redirectOptions in your config control automatic redirects when accessing protected routes. Make sure the callback path matches your redirect URL configuration.
SvelteKit Configuration
SvelteKit requires manual setup, but the patterns are straightforward once you understand hooks and load functions.
Installing Dependencies
npm install @supabase/supabase-js @supabase/ssr
Environment Variables
# .env PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
Creating the Supabase Client
Set up server and browser clients:
// src/lib/supabase.ts
import { createBrowserClient, createServerClient } from '@supabase/ssr'
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
export function createBrowserSupabaseClient() {
return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY)
}
export function createServerSupabaseClient(
cookies: {
getAll: () => Array<{ name: string; value: string }>
setAll: (cookies: Array<{ name: string; value: string; options?: any }>) => void
}
) {
return createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { cookies })
}
Server Hooks for Session Management
// src/hooks.server.ts
import { createServerSupabaseClient } from '$lib/supabase'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerSupabaseClient({
getAll() {
return event.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' })
})
},
})
event.locals.safeGetSession = async () => {
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
if (!session) return { session: null, user: null }
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) return { session: null, user: null }
return { session, user }
}
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version'
},
})
}
TypeScript Definitions
Extend the SvelteKit types:
// src/app.d.ts
import type { Session, SupabaseClient, User } from '@supabase/supabase-js'
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>
}
}
}
export {}
Root Layout for Session State
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
const { session, user } = await safeGetSession()
return { session, user }
}
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { onMount } from 'svelte'
import { invalidate } from '$app/navigation'
import { createBrowserSupabaseClient } from '$lib/supabase'
export let data
let supabase = createBrowserSupabaseClient()
onMount(() => {
const { data: subscription } = supabase.auth.onAuthStateChange((event, session) => {
if (session?.expires_at !== data.session?.expires_at) {
invalidate('supabase:auth')
}
})
return () => subscription.subscription.unsubscribe()
})
</script>
<slot />
Common Configuration Issues
Regardless of framework, these issues frequently trip up developers connecting to self-hosted Supabase:
Wrong URL Format
The Supabase URL must include the protocol and exclude trailing slashes:
# Correct SUPABASE_URL=https://supabase.yourdomain.com # Wrong - missing protocol SUPABASE_URL=supabase.yourdomain.com # Wrong - trailing slash SUPABASE_URL=https://supabase.yourdomain.com/ # Wrong - including /rest/v1 path SUPABASE_URL=https://supabase.yourdomain.com/rest/v1
CORS Errors
If you see CORS errors in your browser console, your Kong configuration may not include the correct allowed origins. Check your Kong configuration includes your frontend domain, or configure CORS headers in your reverse proxy setup.
Mixed HTTP/HTTPS
Self-hosted Supabase should run over HTTPS in production. If your frontend is HTTPS but Supabase is HTTP, modern browsers will block the requests as mixed content. Our SSL setup guide covers certificate configuration.
Authentication Redirect Loops
When using OAuth or magic links, incorrect redirect URL configuration causes infinite loops. The SITE_URL environment variable in your Supabase deployment must match your frontend URL exactly. See the redirect URL configuration guide for troubleshooting steps.
Testing Your Connection
Before building your full application, verify the connection works:
// Quick test in any framework
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!, // Or your framework's equivalent
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// Test database connection
const { data, error } = await supabase.from('any_table').select('*').limit(1)
if (error) {
console.error('Connection failed:', error.message)
} else {
console.log('Connection successful!')
}
If this fails, check:
- Your Supabase instance is running (
docker compose ps) - Kong is accessible at your configured URL
- The anon key matches what's in your Supabase
.env - Network/firewall rules allow traffic to your Supabase port
Security Considerations
When connecting frontend applications to self-hosted Supabase, keep these security practices in mind:
Never expose the service role key: It should only exist in server-side code and environment variables that aren't bundled into client code.
Enable Row Level Security: Self-hosted Supabase doesn't enforce RLS by default on new tables. Every table accessible via the API should have appropriate policies. See our RLS guide.
Use environment variables: Don't hardcode URLs or keys. Use your framework's environment variable system.
Validate on the server: Even with RLS, validate user input in your server-side code before database operations.
Further Reading
- Self-Hosted Supabase Environment Variables - Complete reference for all configuration options
- Custom Domains and SSL Setup - HTTPS configuration for production
- Row Level Security Guide - Securing your data at the database level
- OAuth Provider Configuration - Social login setup for self-hosted instances
Self-hosted Supabase works seamlessly with modern frontend frameworks once configured correctly. The key is understanding that the only real difference from Supabase Cloud is the URL—everything else, from authentication flows to real-time subscriptions, works identically. With your frontend connected, you're ready to build your application with the full power of Postgres and complete control over your infrastructure.
Ready to simplify your self-hosted Supabase deployment? Supascale handles the operational complexity—backups, custom domains, OAuth configuration—so you can focus on building your application.
