Better Auth с Drizzle и Postgres: настройка с нуля
Этот сетап у меня сейчас стоит на трёх сервисах в проде, поэтому опишу его конкретно: какие файлы, какие команды, какие подводные. Цель — за час получить работающий вход через email/пароль и Google OAuth, на Postgres и Drizzle, чтобы дальше можно было обвешивать плагинами.
Версии на момент написания: Better Auth 1.1.x, Drizzle 0.30, drizzle-kit 0.21, Node 20, Postgres 16. На Next 14 пример показан, но логика одинаковая для Hono или Express.
Установка
pnpm add better-auth drizzle-orm pg
pnpm add -D drizzle-kit @types/pg tsxНикаких отдельных адаптеров для Better Auth ставить не надо: better-auth/adapters/drizzle идёт в основном пакете.
База и подключение Drizzle
Начну с самого нижнего слоя. src/db/index.ts:
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
export const db = drizzle(pool, { schema });В .env:
DATABASE_URL=postgres://app:app@localhost:5432/app
BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...Секрет генерирую один раз и кладу в менеджер секретов в проде. BETTER_AUTH_URL — публичный адрес приложения, по которому будут возвращаться OAuth-провайдеры.
Конфигурация auth
Файл src/lib/auth.ts. Это основа, которую переиспользуют и серверные обработчики, и React-клиент.
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '../db';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 дней
updateAge: 60 * 60 * 24, // обновляем сессию раз в сутки
},
advanced: {
cookiePrefix: 'app',
useSecureCookies: process.env.NODE_ENV === 'production',
},
});На что обращу внимание:
requireEmailVerification: true— без него регистрация подтверждает email самостоятельно, что плохо. С этой опцией пользователь должен подтвердить ссылку из письма перед первым входом.updateAge— раз в сутки сессия в БД обновляется. Реже — sliding-expiration не работает; чаще — лишняя запись на каждый запрос.cookiePrefixважен в монорепе с несколькими приложениями: иначе куки от auth перехлестнутся.
Схема БД
Better Auth ожидает четыре таблицы: user, session, account, verification. Имена и колонки можно посмотреть в better-auth/cli, но я предпочитаю руками держать в Drizzle-схеме — чтобы видеть, какие поля где.
// src/db/schema.ts
import { pgTable, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull().default(false),
name: text('name'),
image: text('image'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const session = pgTable('session', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const account = pgTable('account', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
accountId: text('account_id').notNull(),
password: text('password'),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
scope: text('scope'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const verification = pgTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});Дальше — обычная связка drizzle-kit:
pnpm drizzle-kit generate
pnpm drizzle-kit migrateЕсли ты хочешь, чтобы Better Auth сам сделал DDL — есть npx @better-auth/cli generate, он положит файл схемы. Я предпочитаю руками контролировать структуру.
Подключение к Next.js
Создаём catch-all-роут. app/api/auth/[...all]/route.ts:
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth.handler);Всё. Better Auth сам разруливает /api/auth/sign-in, /api/auth/sign-up, /api/auth/callback/google, /api/auth/sign-out и прочие endpoint-ы.
Клиент
// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
});
export const { useSession, signIn, signUp, signOut } = authClient;В компонентах:
'use client';
import { useSession, signIn, signOut } from '@/lib/auth-client';
export function UserBadge() {
const { data: session, isPending } = useSession();
if (isPending) return null;
if (!session) return <button onClick={() => signIn.social({ provider: 'google' })}>Войти</button>;
return (
<div>
Привет, {session.user.name}
<button onClick={() => signOut()}>Выйти</button>
</div>
);
}Серверная проверка сессии
В Next.js Server Components я делаю так:
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
export async function getSession() {
return auth.api.getSession({ headers: headers() });
}В Server Action или middleware — то же самое, разница только в том, как ты получаешь объект headers. Удобно, что один и тот же auth-инстанс работает на всех слоях.
Email с подтверждением
Better Auth просит callback, который умеет отправлять письмо. У меня обычно это Resend или Postmark.
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await sendMail({
to: user.email,
subject: 'Сброс пароля',
html: `<p><a href="${url}">Сбросить пароль</a></p>`,
});
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await sendMail({
to: user.email,
subject: 'Подтвердите почту',
html: `<p><a href="${url}">Подтвердить адрес</a></p>`,
});
},
},URL Better Auth собирает сам, тебе остаётся положить его в письмо.
OAuth Google: что обязательно проверить
Самая частая ошибка — несовпадение redirect URI. У Better Auth он формируется как {BETTER_AUTH_URL}/api/auth/callback/google. Этот URL надо добавить в OAuth Client консоли Google. Если деплоишься на Vercel, не забудь добавить и preview-домены, иначе превью будет падать.
Второй момент — scope. По умолчанию подтягиваются email и profile. Если хочешь дополнительные данные (например, refresh token из Google) — указывай явно:
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
accessType: 'offline',
prompt: 'consent',
},
},Rate limiting
Включаю всегда. По умолчанию используется in-memory store, для сервиса с одним инстансом этого хватает. На multi-instance — указываю Redis-стор.
rateLimit: {
enabled: true,
window: 60,
max: 30,
// storage: redis(...) — для multi-instance
},30 запросов в минуту с одного IP к auth-эндпоинтам — нормальный потолок. На /sign-in более строго — настраивается отдельной опцией customRules.
Что мониторить в проде
- Размер таблицы
session. Если ты не подчищаешь истёкшие сессии — она будет расти. - Количество ошибок 401 на endpoint-ах /api/*. Скачок — обычно знак, что куки не доходят (не тот домен, не тот
SameSite). - Очередь email. Если письма верификации не уходят — пользователи застревают на первом шаге.
Шпаргалка по граблям
- В Next 14 на App Router — обязательно
export const dynamic = 'force-dynamic'на странице, читающей сессию. Без этого Server Component кэшируется и сессия отстаёт. - На монорепе с несколькими доменами —
cookieOptions.domainи общий префикс куки. Без этого один SSO между сабдоменами не получится. BETTER_AUTH_URLна проде — это публичный URL, не localhost и не контейнерное имя. Звучит банально, но эта ошибка нашлась у меня дважды.
Этот сетап стабильный. Логин email/пароль, Google OAuth, серверные сессии в Postgres, email-верификация и rate limiting — всё с минимумом кода. Дальше можно подключать плагины: organizations, magic-link, passkeys, 2FA. Но базовый набор у меня обычно живёт без изменений месяцами.