VelaVela
Better Auth

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: false in 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 so AuthGuard skips the right paths.

  • Set basePath: false in module options to disable basePath logic entirely (e.g., when auth lives on a separate worker).

On this page