Configuration
BetterAuthModule.forRoot vs forRootAsync, options reference, and patterns for composing better-auth plugins through Vela's DI.
Options reference
interface BetterAuthModuleOptions {
/** Pre-constructed better-auth instance — `betterAuth({ ... })`. */
auth: BetterAuthInstance;
/** Base path for better-auth handler. Default '/api/auth'. Set false to skip mount. */
basePath?: string | false;
/** Register AuthGuard as APP_GUARD (deny-by-default). Default false. */
isGlobal?: boolean;
/** Default policy when AuthGuard runs and no session present. Default 'deny'. */
defaultPolicy?: 'deny' | 'allow';
/** Mount the /api/auth/* catch-all controller. Default true. */
mountHandler?: boolean;
}forRoot — synchronous
Use when betterAuth({...}) can be built at module-load time:
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import { BetterAuthModule } from '@velajs/better-auth';
const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
emailAndPassword: { enabled: true },
});
@Module({
imports: [BetterAuthModule.forRoot({ auth, isGlobal: true })],
})
class AppModule {}auth is registered eagerly; AuthGuard and the catch-all controller resolve it at module load.
forRootAsync — DI'd, deferred
Use when the inputs to betterAuth({...}) depend on values from the DI graph — especially when those values are only available at request time (Cloudflare bindings, Deno Deploy env, etc.).
import { D1Module, D1Service } from '@velajs/cloudflare';
import { drizzle } from 'drizzle-orm/d1';
@Module({
imports: [
D1Module.forRoot({ binding: 'DB' }),
BetterAuthModule.forRootAsync({
inject: [D1Service],
useFactory: (d1) => // d1: D1Service — inferred
betterAuth({
database: drizzleAdapter(drizzle(d1.database), { provider: 'sqlite' }),
emailAndPassword: { enabled: true },
}),
isGlobal: true,
}),
],
})
class AppModule {}Key timing: useFactory runs lazily on first BetterAuthService.auth access — typically inside AuthGuard.canActivate on the first protected request, or inside the catch-all controller on the first /api/auth/* request. At module-load there's no factory call, no D1 binding access. By the time the factory runs, runtime adapters (createCloudflareApp's env-capture middleware, etc.) have populated bindings.
Typed inject parameters
useFactory parameters are inferred from the literal inject tuple (no as const, no annotations) thanks to vela 1.8.2's const Inject type parameter:
BetterAuthModule.forRootAsync({
inject: [D1Service, ConfigService, EmailService],
useFactory: (d1, config, email) => // all three params inferred
betterAuth({
database: drizzleAdapter(drizzle(d1.database), { provider: 'sqlite' }),
secret: config.get('AUTH_SECRET'),
plugins: [
magicLink({ sendMagicLink: (data) => email.send(data) }),
],
}),
})Composing plugins through DI
Three composition idioms, picked by coupling needs.
Pattern A — inline
BetterAuthModule.forRoot({
auth: betterAuth({
database,
plugins: [magicLink({ sendMagicLink: simpleSendFn }), apiKey(), twoFactor()],
}),
isGlobal: true,
})Pattern B — DI'd plugin construction
BetterAuthModule.forRootAsync({
inject: [D1Service, EmailService, ConfigService],
useFactory: (d1, email, config) =>
betterAuth({
database: drizzleAdapter(drizzle(d1.database), { provider: 'sqlite' }),
plugins: [
magicLink({ sendMagicLink: (data) => email.send(data) }), // DI'd email
apiKey({ defaultPrefix: config.get('API_KEY_PREFIX') }),
twoFactor(),
],
}),
isGlobal: true,
})Pattern C — modular plugin feature modules
Each feature ships a self-contained Vela module that exports a plugin token. AppModule composes them like LEGO.
// magic-link-auth.module.ts
export const MAGIC_LINK_PLUGIN = new InjectionToken<ReturnType<typeof magicLink>>(
'app.MagicLinkPlugin',
);
@Module({
providers: [
EmailService,
{
provide: MAGIC_LINK_PLUGIN,
inject: [EmailService],
useFactory: (email) =>
magicLink({ sendMagicLink: (d) => email.send({ to: d.email, link: d.url }) }),
},
],
exports: [MAGIC_LINK_PLUGIN],
})
export class MagicLinkAuthModule {}// app.module.ts
@Module({
imports: [
MagicLinkAuthModule,
OAuthAuthModule,
BetterAuthModule.forRootAsync({
imports: [MagicLinkAuthModule, OAuthAuthModule],
inject: [MAGIC_LINK_PLUGIN, OAUTH_PLUGIN],
useFactory: (magicLink, oauth) =>
betterAuth({
database,
plugins: [magicLink, oauth],
}),
isGlobal: true,
}),
],
})
class AppModule {}This is the substrate for an ecosystem — third-party packages can ship their own auth-extension modules that expose plugin tokens. Consumers compose them; the underlying betterAuth({...}) instance gets every plugin via DI.
basePath and custom mounting
The catch-all controller mounts at /api/auth/* by default. Two ways to change it:
-
Set
mountHandler: falsein module options + mount yourself with a custom controller:@Public(true) @Controller('/auth') class CustomCatchallController { constructor(@Inject(BetterAuthService) private auth: BetterAuthService) {} @All('/*') handle(@Req() c: Context) { return this.auth.handler(c.req.raw); } }Pass
basePath: '/auth'in module options soAuthGuardskips the right paths. -
Set
basePath: falsein module options to disable basePath logic entirely (e.g., when auth lives on a separate worker).