Integrations
Better Auth
Mount Better Auth's handler behind CORS, and read or require the session with middleware from @rhythmjs/better-auth.
Better Auth is framework-agnostic: its handler takes a standard Request and returns a Response. @rhythmjs/better-auth mounts it on a Rhythm app and gives you session middleware. Create your auth instance as Better Auth's docs describe, and import it here.
Install#
bun add better-auth @rhythmjs/better-auth@rhythmjs/http and @rhythmjs/security come with the package.
Mount the handler#
Register betterAuthModule.forRoot({ auth }). It mounts auth.handler at path (default: the instance's basePath, else /api/auth) and provides auth. Nothing reaches the context until you export it in the register callback, so export auth for the middleware below.
// src/app.module.ts
import { betterAuthModule } from "@rhythmjs/better-auth";
import { Rhythm } from "@rhythmjs/rhythm";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import { auth } from "./lib/auth";
export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" }).register(
betterAuthModule.forRoot({ auth, path: "/api/auth" }),
(m) => ({ auth: m.auth }),
);Under the hood the module uses mount. Without the module, authHandler({ auth }) is the bare mounting middleware; provide ctx.auth yourself in that case.
CORS#
For a frontend on another origin, use the cors exported by @rhythmjs/better-auth (it is cors from @rhythmjs/security/cors, unchanged). Put it before the module, so preflights are answered and Better Auth's own responses carry the headers. Cookie sessions need credentials: true, and the origin must also be in Better Auth's trustedOrigins: Better Auth checks origins separately from CORS.
import { betterAuthModule, cors } from "@rhythmjs/better-auth";
export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" })
.use(cors({ origin: "http://localhost:3001", credentials: true, allowHeaders: ["Content-Type", "Authorization"] }))
.register(betterAuthModule.forRoot({ auth, path: "/api/auth" }), (m) => ({ auth: m.auth }));Session middleware#
Both read ctx.auth, so they go after the register above.
withSession()putssessionanduseron the context, bothnullwhen anonymous.requireSession()answers 401 when there is no session, and its type tells the handler thatctx.sessionandctx.userare set.
import { requireSession, withSession } from "@rhythmjs/better-auth";
export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" })
.use(cors({ origin: "http://localhost:3001", credentials: true }))
.register(betterAuthModule.forRoot({ auth, path: "/api/auth" }), (m) => ({ auth: m.auth }))
.use(withSession())
.use(appController.middleware());Guard a route with requireSession(). Its context type needs AuthContext and SessionContext, both exported by the package. Put the guard on the route: a router-level guard narrows the router's context, and that router would then not type-check when mounted.
// src/app.controller.ts
import { requireSession, type AuthContext, type SessionContext } from "@rhythmjs/better-auth";
import { RhythmRouter } from "@rhythmjs/router";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
export type AppContext = RhythmHttpContext & AuthContext & SessionContext;
export const appController = new RhythmRouter<AppContext>().get("/me", requireSession(), (ctx) => {
ctx.json({ id: ctx.user.id, name: ctx.user.name, email: ctx.user.email });
});session and user have Better Auth's base types. For plugin or custom fields, call ctx.auth.api.getSession({ headers: ctx.request.headers }) yourself: it is typed from your instance. For roles, check ctx.user.role in a guard of your own.
Things to check#
- Better Auth rate-limits its own endpoints; Rhythm middleware does not see requests that the module answers.
- Set
BETTER_AUTH_SECRETandBETTER_AUTH_URLoutside local runs.
The full app, with tests, is examples/better-auth, built from the template.