Why Better Auth Needed a Separate CLI Config in Astro
While setting up Better Auth with Astro, Drizzle, and Neon PostgreSQL, I ran into a useful architecture lesson: runtime application code and CLI tooling code often need different entry points, even when they configure the same underlying system.
The issue appeared when trying to generate the Better Auth Drizzle schema with:
pnpm dlx @better-auth/cli@latest generate --config ./src/lib/auth/server.ts --output ./src/lib/db/schema/auth.ts --yes
At first, this seemed reasonable. src/lib/auth/server.ts was the file where the Better Auth instance lived, so pointing the Better Auth CLI at that file felt natural.
But the command failed with:
Cannot find module 'astro:env/server'
The failure was not really about Better Auth. It was about boundaries.
The actual problem
The app uses Astro’s server environment system:
import { getSecret } from "astro:env/server";
That is correct for Astro runtime code.
But the Better Auth CLI does not run inside Astro. It runs as a plain Node-based CLI process. So when the CLI tried to import the app’s runtime auth file, it followed this chain:
Better Auth CLI
→ src/lib/auth/server.ts
→ src/lib/config/env.ts
→ astro:env/server
→ failure
The CLI could not resolve astro:env/server because that module exists in Astro’s runtime/build context, not in a normal Node CLI context.
This is similar to the earlier Drizzle setup. Drizzle Kit also runs outside Astro, which is why drizzle.config.ts uses:
import "dotenv/config";
const databaseUrl = process.env.DATABASE_URL_DIRECT;
if (!databaseUrl) {
throw new Error("DATABASE_URL_DIRECT is required for Drizzle Kit migrations.");
}
The difference is that Drizzle already had a dedicated CLI config file: drizzle.config.ts.
Better Auth needed the same kind of separation.
The right mental model
There are now two different consumers of the auth configuration:
Astro runtime
Better Auth CLI
The Astro app needs a real runtime Better Auth instance for login, logout, sessions, and auth routes.
The Better Auth CLI needs to understand the auth configuration so it can generate the correct Drizzle schema.
Those two consumers need the same auth behavior, but they should not load environment variables the same way.
Runtime app code should use Astro’s env boundary.
CLI tooling should use dotenv and process.env.
That led to this split:
src/lib/auth/options.ts shared Better Auth options
src/lib/auth/server.ts Astro runtime auth instance
better-auth.config.ts Better Auth CLI config
src/lib/auth/options.ts
This file contains the shared Better Auth configuration logic.
It does not create the Better Auth instance directly. It only creates the options object that will later be passed into betterAuth(...).
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import type { betterAuth } from "better-auth";
type AuthOptions = Parameters<typeof betterAuth>[0];
type DrizzleDatabase = Parameters<typeof drizzleAdapter>[0];
type CreateAuthOptionsInput = {
db: DrizzleDatabase;
baseURL: string;
secret: string;
};
export function createAuthOptions({
db,
baseURL,
secret,
}: CreateAuthOptionsInput): AuthOptions {
return {
baseURL,
secret,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
disableSignUp: true,
minPasswordLength: 12,
},
};
}
At runtime, after TypeScript types are removed, the file is basically just this:
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
export function createAuthOptions({ db, baseURL, secret }) {
return {
baseURL,
secret,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
disableSignUp: true,
minPasswordLength: 12,
},
};
}
So the function is simple:
Receive db, baseURL, and secret.
Return a Better Auth configuration object.
The TypeScript types are just compile-time guardrails.
Understanding the TypeScript types
This line can look intimidating:
type AuthOptions = Parameters<typeof betterAuth>[0];
But it means:
Get the type of the first argument accepted by betterAuth(...).
Use that as the return type of createAuthOptions().
So instead of manually guessing what Better Auth options should look like, TypeScript asks the installed Better Auth package directly.
This line does the same thing for the Drizzle adapter:
type DrizzleDatabase = Parameters<typeof drizzleAdapter>[0];
It means:
Get the type of the first argument accepted by drizzleAdapter(...).
Use that as the required db type.
That protects the function from receiving the wrong kind of database client.
Then this type defines what the factory function expects:
type CreateAuthOptionsInput = {
db: DrizzleDatabase;
baseURL: string;
secret: string;
};
So this is valid:
createAuthOptions({
db,
baseURL: "https://example.com",
secret: "some-long-secret",
});
But TypeScript would complain about something like:
createAuthOptions({
db,
baseURL: 123,
secret: null,
});
The types do not create the object. They only make sure the input and output are shaped correctly.
src/lib/auth/server.ts
This file is the real Astro runtime auth entry point.
import { betterAuth } from "better-auth";
import { env } from "@/lib/config/env";
import { db } from "@/lib/db/client";
import { createAuthOptions } from "./options";
export const auth = betterAuth(
createAuthOptions({
db,
baseURL: env.app.baseUrl,
secret: env.auth.secret,
}),
);
This file is used by the actual app.
The auth route calls the Better Auth handler from this runtime instance:
import type { APIRoute } from "astro";
import { auth } from "@/lib/auth/server";
export const ALL: APIRoute = async (context) => {
return auth.handler(context.request);
};
The runtime flow is:
User logs in
→ request hits /api/auth/*
→ Astro route calls auth.handler(request)
→ auth comes from src/lib/auth/server.ts
→ server.ts uses Astro env and the app database client
→ Better Auth reads/writes auth tables through Drizzle
This file should use the app’s real runtime environment boundary:
env.app.baseUrl
env.auth.secret
That keeps app environment access centralized and avoids scattering raw process.env calls across the application.
better-auth.config.ts
This file is only for the Better Auth CLI.
import "dotenv/config";
import { Pool } from "@neondatabase/serverless";
import { betterAuth } from "better-auth";
import { drizzle } from "drizzle-orm/neon-serverless";
import { createAuthOptions } from "./src/lib/auth/options";
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for Better Auth CLI schema generation.`);
}
return value;
}
const pool = new Pool({
connectionString: requiredEnv("DATABASE_URL"),
});
const db = drizzle(pool);
export const auth = betterAuth(
createAuthOptions({
db,
baseURL: requiredEnv("APP_BASE_URL"),
secret: requiredEnv("AUTH_SECRET"),
}),
);
export default auth;
This file avoids importing Astro runtime code.
Instead of this failing path:
Better Auth CLI
→ src/lib/auth/server.ts
→ src/lib/config/env.ts
→ astro:env/server
→ failure
the CLI now uses this path:
Better Auth CLI
→ better-auth.config.ts
→ src/lib/auth/options.ts
→ success
The CLI config uses:
import "dotenv/config";
and:
process.env
because CLI tools run outside Astro.
This is the same general idea as drizzle.config.ts.
Why options.ts is worth having
Technically, the Better Auth CLI config could duplicate the same config object from server.ts.
That would work, but it would be fragile.
For example, this would be risky:
server.ts has usePlural: true
better-auth.config.ts forgets usePlural: true
generated schema no longer matches runtime behavior
Or:
server.ts disables public signup
better-auth.config.ts forgets that option
schema or auth behavior drifts over time
The shared factory avoids that.
The durable auth behavior lives in one place:
src/lib/auth/options.ts
Then each environment injects its own dependencies:
src/lib/auth/server.ts
→ injects Astro env and runtime db
better-auth.config.ts
→ injects dotenv env and CLI db
Final pattern
The final architecture looks like this:
src/lib/auth/options.ts
Shared Better Auth behavior.
No env loading.
No database connection creation.
No Astro dependency.
src/lib/auth/server.ts
Runtime Better Auth instance.
Uses Astro env.
Uses app database client.
Used by /api/auth/[...all].ts.
better-auth.config.ts
CLI-only Better Auth instance.
Uses dotenv and process.env.
Creates a CLI-safe Drizzle client.
Used only by Better Auth schema generation.
The key lesson:
Runtime app code and CLI tooling code can share behavior,
but they should not necessarily share the same entry point.
For Astro projects, this matters because astro:env/server is valid inside Astro, but not inside every external CLI that imports project files.
The clean boundary is:
Astro runtime code:
use Astro env
CLI config code:
use dotenv + process.env
Shared business/config behavior:
extract into a dependency-free factory
This keeps the runtime app clean, keeps tooling working, and avoids duplicated auth configuration.