|
| 1 | +# NestJS Adapter |
| 2 | + |
| 3 | +## Setup |
| 4 | + |
| 5 | +Install NestJS as a peer dependency: |
| 6 | + |
| 7 | +```bash |
| 8 | +pnpm add @nestjs/common @nestjs/core |
| 9 | +``` |
| 10 | + |
| 11 | +The 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. |
| 12 | + |
| 13 | +`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()`. |
| 14 | + |
| 15 | +## Basic controller with auth |
| 16 | + |
| 17 | +```ts |
| 18 | +// games.controller.ts |
| 19 | +import { Controller, Get, UseGuards } from '@nestjs/common' |
| 20 | +import { withSupabase, SupabaseCtx } from '@supabase/server/adapters/nestjs' |
| 21 | +import type { SupabaseContext } from '@supabase/server' |
| 22 | + |
| 23 | +@Controller('games') |
| 24 | +@UseGuards(withSupabase({ auth: 'user' })) |
| 25 | +export class GamesController { |
| 26 | + @Get() |
| 27 | + async list(@SupabaseCtx() ctx: SupabaseContext) { |
| 28 | + const { data } = await ctx.supabase.from('favorite_games').select() |
| 29 | + return data |
| 30 | + } |
| 31 | + |
| 32 | + @Get('me') |
| 33 | + me(@SupabaseCtx('userClaims') user: SupabaseContext['userClaims']) { |
| 34 | + return user |
| 35 | + } |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +`@SupabaseCtx()` returns the entire `SupabaseContext` (`supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, `authMode`, `authKeyName`). Pass a key (`@SupabaseCtx('supabase')`) to extract a single field. |
| 40 | + |
| 41 | +### Typing your database |
| 42 | + |
| 43 | +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: |
| 44 | + |
| 45 | +```ts |
| 46 | +import type { SupabaseContext } from '@supabase/server' |
| 47 | +import type { Database } from './database.types' |
| 48 | + |
| 49 | +@Get() |
| 50 | +async list(@SupabaseCtx() ctx: SupabaseContext<Database>) { |
| 51 | + const { data } = await ctx.supabase.from('favorite_games').select() |
| 52 | + return data |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +## Per-route auth |
| 57 | + |
| 58 | +Apply different auth modes per controller or per handler — the closest `@UseGuards()` wins: |
| 59 | + |
| 60 | +```ts |
| 61 | +import { Controller, Get, Post, UseGuards } from '@nestjs/common' |
| 62 | +import { withSupabase, SupabaseCtx } from '@supabase/server/adapters/nestjs' |
| 63 | +import type { SupabaseContext } from '@supabase/server' |
| 64 | + |
| 65 | +@Controller() |
| 66 | +export class AppController { |
| 67 | + // Public — no guard |
| 68 | + @Get('health') |
| 69 | + health() { |
| 70 | + return { status: 'ok' } |
| 71 | + } |
| 72 | + |
| 73 | + // User-authenticated route |
| 74 | + @Get('todos') |
| 75 | + @UseGuards(withSupabase({ auth: 'user' })) |
| 76 | + async todos(@SupabaseCtx() ctx: SupabaseContext) { |
| 77 | + const { data } = await ctx.supabase.from('todos').select() |
| 78 | + return data |
| 79 | + } |
| 80 | + |
| 81 | + // Secret-key-protected admin route |
| 82 | + @Post('admin/sync') |
| 83 | + @UseGuards(withSupabase({ auth: 'secret' })) |
| 84 | + async sync(@SupabaseCtx() ctx: SupabaseContext) { |
| 85 | + const { data } = await ctx.supabaseAdmin |
| 86 | + .from('audit_log') |
| 87 | + .insert({ action: 'sync' }) |
| 88 | + return data |
| 89 | + } |
| 90 | + |
| 91 | + // Dual auth — users or services |
| 92 | + @Get('reports') |
| 93 | + @UseGuards(withSupabase({ auth: ['user', 'secret'] })) |
| 94 | + reports(@SupabaseCtx('authMode') authMode: SupabaseContext['authMode']) { |
| 95 | + return { authMode } |
| 96 | + } |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +## App-wide guard |
| 101 | + |
| 102 | +Apply the guard globally with `app.useGlobalGuards()`: |
| 103 | + |
| 104 | +```ts |
| 105 | +// main.ts |
| 106 | +import { NestFactory } from '@nestjs/core' |
| 107 | +import { withSupabase } from '@supabase/server/adapters/nestjs' |
| 108 | +import { AppModule } from './app.module' |
| 109 | + |
| 110 | +async function bootstrap() { |
| 111 | + const app = await NestFactory.create(AppModule) |
| 112 | + app.useGlobalGuards(new (withSupabase({ auth: 'user' }))()) |
| 113 | + await app.listen(3000) |
| 114 | +} |
| 115 | +bootstrap() |
| 116 | +``` |
| 117 | + |
| 118 | +## Multiple guards |
| 119 | + |
| 120 | +`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. |
| 121 | + |
| 122 | +If you need different auth per route, prefer per-route `@UseGuards(...)` without a global guard. |
| 123 | + |
| 124 | +## CORS |
| 125 | + |
| 126 | +The NestJS adapter does not handle CORS. Use NestJS's built-in CORS: |
| 127 | + |
| 128 | +```ts |
| 129 | +// main.ts |
| 130 | +import { NestFactory } from '@nestjs/core' |
| 131 | +import { AppModule } from './app.module' |
| 132 | + |
| 133 | +async function bootstrap() { |
| 134 | + const app = await NestFactory.create(AppModule) |
| 135 | + app.enableCors({ origin: 'https://myapp.com' }) |
| 136 | + await app.listen(3000) |
| 137 | +} |
| 138 | +bootstrap() |
| 139 | +``` |
| 140 | + |
| 141 | +The `cors` option is excluded from `WithSupabaseConfig` for this adapter. |
| 142 | + |
| 143 | +## Error handling |
| 144 | + |
| 145 | +When auth fails, the adapter throws a NestJS `HttpException`. The original `AuthError` is available via `cause`. Add an exception filter to format the response: |
| 146 | + |
| 147 | +```ts |
| 148 | +// supabase-auth.filter.ts |
| 149 | +import { |
| 150 | + ArgumentsHost, |
| 151 | + Catch, |
| 152 | + ExceptionFilter, |
| 153 | + HttpException, |
| 154 | +} from '@nestjs/common' |
| 155 | +import { AuthError } from '@supabase/server' |
| 156 | +import type { Response } from 'express' |
| 157 | + |
| 158 | +@Catch(HttpException) |
| 159 | +export class SupabaseAuthFilter implements ExceptionFilter { |
| 160 | + catch(exception: HttpException, host: ArgumentsHost) { |
| 161 | + const cause = exception.cause |
| 162 | + if (!(cause instanceof AuthError)) throw exception |
| 163 | + |
| 164 | + const res = host.switchToHttp().getResponse<Response>() |
| 165 | + res.status(cause.status).json({ |
| 166 | + error: cause.message, |
| 167 | + code: cause.code, |
| 168 | + }) |
| 169 | + } |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +Register it globally: |
| 174 | + |
| 175 | +```ts |
| 176 | +// main.ts |
| 177 | +app.useGlobalFilters(new SupabaseAuthFilter()) |
| 178 | +``` |
| 179 | + |
| 180 | +## Environment overrides |
| 181 | + |
| 182 | +Pass `env` to override auto-detected environment variables: |
| 183 | + |
| 184 | +```ts |
| 185 | +@UseGuards( |
| 186 | + withSupabase({ |
| 187 | + auth: 'user', |
| 188 | + env: { url: 'http://localhost:54321' }, |
| 189 | + }), |
| 190 | +) |
| 191 | +``` |
| 192 | + |
| 193 | +## Supabase client options |
| 194 | + |
| 195 | +Forward options to the underlying `createClient()` calls: |
| 196 | + |
| 197 | +```ts |
| 198 | +@UseGuards( |
| 199 | + withSupabase({ |
| 200 | + auth: 'user', |
| 201 | + supabaseOptions: { db: { schema: 'api' } }, |
| 202 | + }), |
| 203 | +) |
| 204 | +``` |
0 commit comments