Findings
CONFIRMED
Better Auth can detect D1 without Drizzle
Installed better-auth@1.6.23 includes a Kysely
adapter path that recognizes an object with
batch, exec, and prepare as
SQLite/D1, then loads an internal D1 SQLite dialect.
Evidence: @better-auth/kysely-adapter package code
checks for "batch" in db && "exec" in db && "prepare" in db
and constructs D1SqliteDialect.
IMPORTANT
D1 platform constraints matter for auth design
Cloudflare D1 is SQLite-compatible and serverless, but each
individual D1 database processes queries one at a time. Current
D1 limits include a maximum 100 bound parameters per query, 30
second maximum SQL query duration, and six simultaneous D1
connections per Worker invocation.
Evidence: Cloudflare D1 docs and D1 limits page retrieved on
2026-06-30.
Design implication
Auth queries should stay small and indexed. Session, account,
verification, and user lookup columns need indexes matching the
Better Auth access patterns.
OUT OF SCOPE
Better Auth Infrastructure is not part of this plan
Do not configure @better-auth/infra, Sentinel, the
Better Auth Infrastructure dashboard, or infrastructure-managed
email/SMS as part of this D1 implementation. The initial scope is
self-hosted Better Auth on the existing Cloudflare Worker and D1
database.
RECOMMENDED
Use explicit SQL migrations, not runtime schema generation
Because this project already manages D1 schema through
migrations/*.sql, add a normal migration for Better
Auth tables instead of relying on Drizzle or generated ORM
migrations.
Drizzle Tradeoffs
| Option |
Pros |
Cons |
| Drizzle adapter |
Strong TypeScript schema modeling, a documented Better Auth
adapter path, generated migrations, query-builder ergonomics,
and easier schema reuse if the rest of the app adopts Drizzle.
|
Adds an ORM dependency and migration workflow this project
does not currently use, creates two database access styles
beside the existing direct D1 SQL code, and can make simple
auth table changes feel heavier than plain SQL migrations.
|
| Non-Drizzle D1/Kysely path |
No Drizzle dependency, aligns with the project's existing
hand-written D1 migrations and direct SQL style, keeps the
runtime database binding simple with database: env.DB,
and avoids introducing an app-wide ORM decision only for auth.
|
Less compile-time schema help for auth migrations, more
responsibility to keep SQL tables synchronized with Better
Auth options/plugins, and fewer project-local abstractions if
auth schema customization grows.
|
For this project, the non-Drizzle path is the better fit because the
application already uses direct D1 APIs, already has SQL migrations,
and the stated requirement is to avoid Drizzle. Drizzle becomes more
attractive only if the broader application wants to standardize on an
ORM/query-builder workflow.
Current Better Auth docs also distinguish the migration workflow: the
built-in Kysely adapter can generate SQL or apply migrations directly,
while the Drizzle adapter generates Drizzle schema and expects
drizzle-kit to generate/apply migrations. That is a
useful Drizzle advantage only if the project wants Drizzle's schema as
the source of truth.
Implementation Plan
1. Replace the static auth singleton with an env-scoped factory
Better Auth needs the D1 binding from the current Worker request
environment. Implement a small factory rather than importing a
module-level auth singleton that lacks access to env.
import { betterAuth } from "better-auth";
export const createAuth = (env: Env) =>
betterAuth({
database: env.DB,
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.BETTER_AUTH_URL],
emailAndPassword: {
enabled: true,
},
plugins: [],
});
Add secrets with wrangler secret put BETTER_AUTH_SECRET
and environment-specific URL configuration. For local
development, use a non-production value in .dev.vars
rather than committing secrets. Better Auth can read
BETTER_AUTH_SECRET and BETTER_AUTH_URL
automatically in conventional Node environments; in this Worker,
passing the values from Cloudflare bindings keeps the request-env
dependency explicit.
2. Route /api/auth/* to auth.handler
In src/worker.ts, branch auth requests before
forwarding to TanStack Start. This is the least invasive Worker
integration because Better Auth already exposes a standard
Fetch-compatible handler.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/auth/")) {
return await createAuth(env).handler(request);
}
return await serverEntry.fetch(request);
},
};
If TanStack Start provides a preferred API route pattern in this
project, the same handler can be mounted there instead, but the
Worker-level branch is straightforward and Cloudflare-native.
3. Fix the client setup
Import createAuthClient and set the same base auth
URL. Do not include sentinelClient or any
@better-auth/infra client plugin.
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: "/api/auth",
});
4. Add session access helpers
Create a server-side helper that calls
createAuth(env).api.getSession({ headers }) so pages,
loaders, and server functions can consistently check the current
user. Apply the guard first to write paths, then to full route
loaders if the app should become private.
5. Remove the unwanted SQLite dependency path
Remove better-sqlite3 imports from application code.
If it is only present because of the placeholder auth module, do
not add it as a project dependency. The D1 binding is the runtime
database.
Migration Plan
Add a migration such as
migrations/0009_add_better_auth_tables.sql. The base
Better Auth schema uses user, session,
account, and verification. SQLite accepts
these table names, but quoting them is safer because
user can be confusing in SQL tooling.
CREATE TABLE IF NOT EXISTS "user" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"emailVerified" INTEGER NOT NULL DEFAULT 0,
"image" TEXT,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "session" (
"id" TEXT PRIMARY KEY NOT NULL,
"expiresAt" INTEGER NOT NULL,
"token" TEXT NOT NULL UNIQUE,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId");
CREATE TABLE IF NOT EXISTS "account" (
"id" TEXT PRIMARY KEY NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" INTEGER,
"refreshTokenExpiresAt" INTEGER,
"scope" TEXT,
"password" TEXT,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId");
CREATE INDEX IF NOT EXISTS "account_provider_account_idx"
ON "account" ("providerId", "accountId");
CREATE TABLE IF NOT EXISTS "verification" (
"id" TEXT PRIMARY KEY NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" INTEGER NOT NULL,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS "verification_identifier_idx"
ON "verification" ("identifier");
Confirm the final schema against the exact Better Auth options and
plugins used. Plugins such as organizations, admin, 2FA, passkeys,
or database-backed rate limiting add more tables or columns. As a
pre-implementation aid, run npx auth@latest generate --config src/lib/auth.ts --output schema.sql
against the final auth config and compare the generated SQL with the
hand-written D1 migration before applying it.