Install Elysia as a peer dependency:
pnpm add elysiaThe adapter exports its own withSupabase that returns an Elysia plugin instead of a fetch handler.
import { Elysia } from 'elysia'
import { withSupabase } from '@supabase/server/adapters/elysia'
const app = new Elysia()
.use(withSupabase({ auth: 'user' }))
.get('/todos', async ({ supabaseContext }) => {
const { data } = await supabaseContext.supabase.from('todos').select()
return data
})
app.listen(3000)The context is available as supabaseContext in your route handlers and contains the same SupabaseContext fields as the main withSupabase wrapper: supabase, supabaseAdmin, userClaims, jwtClaims, and authMode.
Apply different auth modes to different routes by using the plugin on scoped route groups:
import { Elysia } from 'elysia'
import { withSupabase } from '@supabase/server/adapters/elysia'
const app = new Elysia()
// Public route — no auth
.get('/health', () => ({ status: 'ok' }))
// User-authenticated routes
.group('/api', (app) =>
app
.use(withSupabase({ auth: 'user' }))
.get('/todos', async ({ supabaseContext }) => {
const { data } = await supabaseContext.supabase.from('todos').select()
return data
}),
)
// Secret-key-protected admin routes
.group('/admin', (app) =>
app
.use(withSupabase({ auth: 'secret' }))
.post('/sync', async ({ supabaseContext }) => {
const { data } = await supabaseContext.supabaseAdmin
.from('audit_log')
.insert({ action: 'sync' })
return data
}),
)
app.listen(3000)If a previous plugin already resolved supabaseContext, subsequent withSupabase calls skip auth. This allows chaining plugins without redundant work.
Important: The plugin calls .as('scoped') so its resolve hook propagates one level up to the parent app — routes registered after .use(withSupabase(...)) will see supabaseContext. The skip-if-set pattern cannot make a route stricter than an already-resolved context.
For routes that need different auth than the rest of the app, use scoped .group() with .use(withSupabase(...)) without an app-wide plugin (see the "Per-route auth" section above).
The Elysia adapter does not handle CORS — the cors option is excluded from its config type. Use Elysia's CORS plugin:
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { withSupabase } from '@supabase/server/adapters/elysia'
const app = new Elysia()
.use(cors())
.use(withSupabase({ auth: 'user' }))
.get('/todos', async ({ supabaseContext }) => {
const { data } = await supabaseContext.supabase.from('todos').select()
return data
})
app.listen(3000)When auth fails, the plugin throws a SupabaseError. The HTTP status is on .status directly, and the original AuthError is available as the typed .cause. Discriminate in onError via code === 'SupabaseError':
import { Elysia } from 'elysia'
import { withSupabase } from '@supabase/server/adapters/elysia'
const app = new Elysia()
.use(withSupabase({ auth: 'user' }))
.onError(({ code, error, status }) => {
if (code !== 'SupabaseError') return
return status(error.status as 401, {
error: error.message,
code: error.cause.code,
})
})
.get('/todos', async ({ supabaseContext }) => {
const { data } = await supabaseContext.supabase.from('todos').select()
return data
})
app.listen(3000)Without a custom onError, Elysia uses the status property on the thrown SupabaseError to set the response status automatically (401 for auth failures, 500 for internal errors).
Pass env to override auto-detected environment variables, same as the main wrapper:
app.use(withSupabase({ auth: 'user', env: { url: 'http://localhost:54321' } }))Forward options to the underlying createClient() calls:
app.use(
withSupabase({
auth: 'user',
supabaseOptions: { db: { schema: 'api' } },
}),
)