Install NestJS as a peer dependency:
pnpm add @nestjs/common @nestjs/coreThe adapter exports withSupabase (a guard factory) and SupabaseCtx (a param decorator). Together they replace the c.var.supabaseContext / event.context.supabaseContext patterns from the Hono and H3 adapters.
withSupabase(config) returns a CanActivate guard class. The guard reads the underlying request (Express or Fastify), verifies credentials with @supabase/server/core, and attaches the resulting SupabaseContext to request.supabaseContext. From any handler you can pull it out with @SupabaseCtx().
// games.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common'
import { withSupabase, SupabaseCtx } from '@supabase/server/adapters/nestjs'
import type { SupabaseContext } from '@supabase/server'
@Controller('games')
@UseGuards(withSupabase({ auth: 'user' }))
export class GamesController {
@Get()
async list(@SupabaseCtx() ctx: SupabaseContext) {
const { data } = await ctx.supabase.from('favorite_games').select()
return data
}
@Get('me')
me(@SupabaseCtx('userClaims') user: SupabaseContext['userClaims']) {
return user
}
}@SupabaseCtx() returns the entire SupabaseContext (supabase, supabaseAdmin, userClaims, jwtClaims, authMode, authKeyName). Pass a key (@SupabaseCtx('supabase')) to extract a single field.
The guard does not thread a Database generic, so @SupabaseCtx() resolves to SupabaseContext<unknown> by default. To get typed table access, annotate the parameter at the handler:
import type { SupabaseContext } from '@supabase/server'
import type { Database } from './database.types'
@Get()
async list(@SupabaseCtx() ctx: SupabaseContext<Database>) {
const { data } = await ctx.supabase.from('favorite_games').select()
return data
}Apply different auth modes per controller or per handler — the closest @UseGuards() wins:
import { Controller, Get, Post, UseGuards } from '@nestjs/common'
import { withSupabase, SupabaseCtx } from '@supabase/server/adapters/nestjs'
import type { SupabaseContext } from '@supabase/server'
@Controller()
export class AppController {
// Public — no guard
@Get('health')
health() {
return { status: 'ok' }
}
// User-authenticated route
@Get('todos')
@UseGuards(withSupabase({ auth: 'user' }))
async todos(@SupabaseCtx() ctx: SupabaseContext) {
const { data } = await ctx.supabase.from('todos').select()
return data
}
// Secret-key-protected admin route
@Post('admin/sync')
@UseGuards(withSupabase({ auth: 'secret' }))
async sync(@SupabaseCtx() ctx: SupabaseContext) {
const { data } = await ctx.supabaseAdmin
.from('audit_log')
.insert({ action: 'sync' })
return data
}
// Dual auth — users or services
@Get('reports')
@UseGuards(withSupabase({ auth: ['user', 'secret'] }))
reports(@SupabaseCtx('authMode') authMode: SupabaseContext['authMode']) {
return { authMode }
}
}Apply the guard globally with app.useGlobalGuards():
// main.ts
import { NestFactory } from '@nestjs/core'
import { withSupabase } from '@supabase/server/adapters/nestjs'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.useGlobalGuards(new (withSupabase({ auth: 'user' }))())
await app.listen(3000)
}
bootstrap()withSupabase always runs, even if a previous guard already set request.supabaseContext. NestJS executes guards in order (global → controller → handler), so a handler-level guard naturally tightens what a global guard set: the later guard re-authenticates with its own config and either rejects the request or overwrites the context. The innermost guard wins.
If you need different auth per route, prefer per-route @UseGuards(...) without a global guard.
The NestJS adapter does not handle CORS. Use NestJS's built-in CORS:
// main.ts
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.enableCors({ origin: 'https://myapp.com' })
await app.listen(3000)
}
bootstrap()The cors option is excluded from WithSupabaseConfig for this adapter.
When auth fails, the adapter throws a NestJS HttpException. The original AuthError is available via cause. Add an exception filter to format the response:
// supabase-auth.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common'
import { AuthError } from '@supabase/server'
import type { Response } from 'express'
@Catch(HttpException)
export class SupabaseAuthFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const cause = exception.cause
if (!(cause instanceof AuthError)) throw exception
const res = host.switchToHttp().getResponse<Response>()
res.status(cause.status).json({
error: cause.message,
code: cause.code,
})
}
}Register it globally:
// main.ts
app.useGlobalFilters(new SupabaseAuthFilter())Pass env to override auto-detected environment variables:
@UseGuards(
withSupabase({
auth: 'user',
env: { url: 'http://localhost:54321' },
}),
)Forward options to the underlying createClient() calls:
@UseGuards(
withSupabase({
auth: 'user',
supabaseOptions: { db: { schema: 'api' } },
}),
)