Better Auth: an introduction
By Flavio Copes
What Better Auth is and how it works: the auth instance, the database schema, the catch-all route, and the client. Authentication for TypeScript apps, explained.
Authentication is one of those things you need in almost every app, and one of those things you really don’t want to build yourself.
Sessions, password hashing, OAuth flows, email verification… it’s a lot of code, and getting it wrong has real consequences.
For years the answer was either a hosted service (Auth0, Clerk, Supabase Auth) or wiring things up yourself with something like Lucia. Better Auth is the option I reach for now: a TypeScript library that runs inside your app and stores users in your database.
That’s the key difference from hosted services. Your users live in your own tables. No external dashboard, no per-user pricing, no vendor lock-in. It’s just a library.
How it works
Better Auth has two halves:
- a server instance that handles all the auth logic and talks to your database
- a client that your frontend uses to sign users up, sign them in, and read the session
The server instance exposes everything through a single HTTP handler you mount at /api/auth/*. The client calls those endpoints for you. You never write the endpoints yourself.
The server instance
You install it first:
npm i better-auth
Then create the instance, usually in a file called auth.ts:
import { betterAuth } from 'better-auth'
import { Pool } from 'pg'
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
emailAndPassword: {
enabled: true,
},
})
This one connects to Postgres and enables email + password login. Better Auth also works with MySQL and SQLite, and has adapters for Drizzle and Prisma if you already use an ORM.
Want social login? Add the provider and its credentials:
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
},
The database schema
Better Auth needs a few tables: user, session, account, and verification.
You don’t write them by hand. The CLI generates them from your config:
npx @better-auth/cli generate
If you use Drizzle or Prisma, this writes the schema in your ORM’s format. Then you run your normal migrations and you’re done.
Mounting the handler
The instance has a handler function that takes a standard Request and returns a Response. You mount it on a catch-all route.
In Next.js, for example:
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth)
Every framework has its own one-liner for this. Since the handler works on plain Request/Response objects, it runs anywhere the Fetch API works: Node, Bun, Deno, Cloudflare Workers.
The client
On the frontend you create a client:
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/client'
export const authClient = createAuthClient()
Now you can sign users up:
await authClient.signUp.email({
name: 'Flavio',
email: 'flavio@flaviocopes.com',
password: 'a-strong-password',
})
Sign them in:
await authClient.signIn.email({
email: 'flavio@flaviocopes.com',
password: 'a-strong-password',
})
Or with a social provider:
await authClient.signIn.social({ provider: 'github' })
And sign them out:
await authClient.signOut()
Sessions are handled with secure cookies. You don’t manage tokens yourself.
Reading the session
On the client, React and Vue get a reactive useSession hook:
const { data: session } = authClient.useSession()
When the user signs in or out, components using the hook re-render automatically.
On the server, you ask the auth instance directly, passing the request headers so it can read the cookie:
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session) {
// not logged in
}
This is what you use in middleware or route handlers to protect pages.
Plugins
Everything beyond the basics is a plugin: two-factor authentication, magic links, passkeys, organizations and teams, username login. You add them to the server instance and the client, run the CLI to update the schema, and the new endpoints appear.
This is what I like most about the design. The core stays small, and you only pull in what you need.
My advice: if you’re starting a new TypeScript project and you own your database, try Better Auth before reaching for a hosted service. You get the convenience of a managed solution, but the users stay yours.
Related posts about js: