nt storage, paired with native connection pooling for serverless functions.
Drizzle ORM complements this by exposing SQL directly while maintaining strict TypeScript types. Unlike abstract ORMs that generate opaque queries, Drizzle requires explicit schema definitions that map directly to PostgreSQL tables. This transparency prevents N+1 queries, simplifies indexing strategies, and ensures migrations are version-controlled alongside application code.
// src/database/schema.ts
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const platformUsers = pgTable("platform_users", {
identifier: text("id").primaryKey(),
displayName: text("display_name").notNull(),
emailAddress: text("email_address").notNull().unique(),
credentialHash: text("credential_hash").notNull(),
isVerified: boolean("is_verified").default(false).notNull(),
registeredAt: timestamp("registered_at").defaultNow().notNull(),
});
The database client initializes a connection pool optimized for serverless environments. Connection reuse prevents cold-start latency spikes.
// src/database/client.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const connectionPool = postgres(process.env.DATABASE_CONNECTION_STRING!, {
max: 1,
idle_timeout: 20,
prepare: false,
});
export const platformDb = drizzle(connectionPool);
2. Authentication Core Configuration
Better Auth decouples credential management from framework internals by using an adapter pattern. The configuration defines supported flows, session behavior, and database persistence without hardcoding framework-specific middleware.
// src/auth/core.ts
import { createAuthPlatform } from "better-auth";
import { drizzlePersistenceLayer } from "better-auth/adapters/drizzle";
import { platformDb } from "@/database/client";
import { platformUsers } from "@/database/schema";
export const authCore = createAuthPlatform({
database: drizzlePersistenceLayer(platformDb, {
user: platformUsers,
}),
credentialFlow: {
enabled: true,
requireEmailVerification: false,
},
session: {
strategy: "cookie",
maxAge: 60 * 60 * 24 * 7,
secure: process.env.NODE_ENV === "production",
},
});
This configuration explicitly maps the user table, enables email/password flows, and defines session cookie behavior. The adapter handles serialization, while the core manages token generation and validation.
3. API Boundary & Client Bridge
Next.js App Router handles request routing through route handlers. Better Auth provides a framework adapter that converts its internal request/response cycle into standard Next.js handlers.
// src/app/api/auth-platform/[...route]/handler.ts
import { authCore } from "@/auth/core";
import { buildNextHandler } from "better-auth/next-js";
export const { GET, POST } = buildNextHandler(authCore);
On the client side, a lightweight bridge initializes the auth context and exposes typed methods for credential operations.
// src/auth/bridge.ts
import { createAuthBridge } from "better-auth/react";
export const authBridge = createAuthBridge({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});
Shadcn UI components are copied directly into the project, granting full control over markup, styling, and validation logic. This eliminates vendor lock-in and ensures authentication forms align with the application's design system.
// src/app/register/page.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { authBridge } from "@/auth/bridge";
export default function RegistrationView() {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRegistration = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsProcessing(true);
setError(null);
const formElements = event.currentTarget.elements as typeof event.currentTarget.elements & {
displayName: HTMLInputElement;
emailAddress: HTMLInputElement;
password: HTMLInputElement;
};
try {
await authBridge.registerUser({
displayName: formElements.displayName.value,
emailAddress: formElements.emailAddress.value,
password: formElements.password.value,
});
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Registration failed");
} finally {
setIsProcessing(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Account</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleRegistration} className="space-y-4">
<Input name="displayName" placeholder="Display Name" required />
<Input name="emailAddress" type="email" placeholder="Email Address" required />
<Input name="password" type="password" placeholder="Password" required />
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" className="w-full" disabled={isProcessing}>
{isProcessing ? "Processing..." : "Register"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
5. Server-Side Guard Implementation
Route protection leverages Next.js server components to validate sessions before rendering protected content. This prevents client-side hydration mismatches and ensures unauthorized requests never reach sensitive UI logic.
// src/app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { authCore } from "@/auth/core";
import { headers } from "next/headers";
export default async function ProtectedDashboard() {
const session = await authCore.api.retrieveSession({
headers: await headers(),
});
if (!session?.user) {
redirect("/login");
}
return (
<main className="p-8">
<h1 className="text-2xl font-bold">Welcome, {session.user.displayName}</h1>
<p className="mt-2 text-neutral-600">Authenticated dashboard content renders here.</p>
</main>
);
}
Architecture Rationale
Each layer serves a distinct purpose. Next.js manages routing, server components, and edge deployment. Better Auth handles credential validation, session generation, and provider abstraction without framework coupling. Drizzle provides explicit schema definitions and migration control, preventing query bloat. Neon delivers serverless PostgreSQL with compute/storage separation, aligning with stateless function execution. Shadcn supplies unstyled, fully-owned UI primitives that integrate seamlessly with Tailwind CSS.
This composition eliminates the traditional auth bottleneck by treating authentication as a type-safe contract rather than a configuration-heavy utility. Database schemas drive TypeScript types, auth adapters respect those types, and UI components consume validated data. The result is a predictable, maintainable system that scales with serverless deployments.
Pitfall Guide
1. Client-Server Session Context Leakage
Explanation: Attempting to read session data in client components using server-only APIs causes hydration mismatches and runtime errors. Next.js strictly separates server and client execution contexts.
Fix: Always retrieve sessions in server components or route handlers. Pass validated data to client components via props or use a dedicated client bridge that safely serializes session state.
2. Ignoring Neon’s Connection Pooling Limits
Explanation: Serverless functions spawn ephemeral connections. Without explicit pooling configuration, rapid invocations exhaust database connection limits, causing timeout errors.
Fix: Configure the PostgreSQL client with max: 1, idle_timeout: 20, and prepare: false for serverless environments. Use Neon's built-in connection pooling endpoint rather than direct database URIs in production.
3. Hardcoding Environment Secrets in Build Artifacts
Explanation: Embedding BETTER_AUTH_SECRET or database credentials in client-side bundles exposes them to browser inspection. Next.js prefixes NEXT_PUBLIC_ for safe client exposure; omitting this prefix keeps secrets server-only.
Fix: Store sensitive values in .env.local and access them exclusively in server components, route handlers, or auth configuration. Never prefix auth secrets with NEXT_PUBLIC_.
4. Skipping Drizzle Migration Safety Checks
Explanation: Running drizzle-kit push in production without reviewing generated SQL can drop columns, alter constraints, or corrupt existing data.
Fix: Always generate migration files first (drizzle-kit generate), review the SQL output, and apply them through a CI/CD pipeline with rollback capabilities. Use drizzle-kit check to validate schema drift before deployment.
5. Over-Rendering Auth State in Client Components
Explanation: Subscribing to auth state in multiple client components triggers unnecessary re-renders and increases bundle size. React's context API can cause prop drilling or stale closures if misused.
Fix: Centralize auth state in a single provider or use server components for initial data fetching. Pass only necessary user properties to client components, and memoize expensive UI updates.
Explanation: Frontend forms accepting invalid data that bypasses backend validation creates security gaps and database constraint violations.
Fix: Implement shared validation schemas using Zod or Valibot. Apply identical constraints on the client for UX feedback and on the server for enforcement. Ensure Drizzle schema types match validation rules.
7. Neglecting CSRF & Secure Cookie Flags in Production
Explanation: Default cookie configurations may lack SameSite, Secure, or HttpOnly flags, leaving sessions vulnerable to cross-site request forgery or client-side script access.
Fix: Explicitly configure session cookies with secure: true, httpOnly: true, and sameSite: "lax" in production. Better Auth handles most flags automatically, but verify the configuration matches your deployment environment.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP / Prototype | Local PostgreSQL + Basic Email/Password | Fastest iteration, minimal infrastructure overhead | Low (free tier Neon/Drizzle) |
| High Traffic SaaS | Neon Pro + Connection Pooling + OAuth Providers | Scales compute independently, handles concurrent sessions efficiently | Medium (Neon compute credits) |
| Enterprise / Compliance | Self-Hosted PostgreSQL + SAML/SCIM + Audit Logging | Meets regulatory requirements, full data residency control | High (infrastructure + compliance tooling) |
| Edge-First Deployment | Next.js Edge Runtime + Neon Serverless | Low latency globally, stateless session handling | Low-Medium (edge request pricing) |
Configuration Template
# .env.local
DATABASE_CONNECTION_STRING="postgresql://user:password@ep-unique-id.region.aws.neon.tech/dbname?sslmode=require"
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NODE_ENV="development"
// drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: "./src/database/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_CONNECTION_STRING!,
},
strict: true,
verbose: true,
} satisfies Config;
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "2mb",
},
},
headers: async () => [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
},
],
};
export default nextConfig;
Quick Start Guide
- Initialize Project: Run
npx create-next-app@latest my-auth-app --typescript --tailwind --app --src-dir. Navigate into the directory.
- Install Dependencies: Execute
npm install better-auth drizzle-orm drizzle-kit postgres @neondatabase/serverless. Initialize Shadcn UI with npx shadcn@latest init and add required components.
- Configure Database: Create a Neon project, copy the connection string, and add it to
.env.local. Run npx drizzle-kit generate followed by npx drizzle-kit migrate to apply the schema.
- Wire Auth Core: Create the auth configuration file with the Drizzle adapter, set up the API route handler, and initialize the client bridge. Add registration and login pages using Shadcn components.
- Verify & Deploy: Test email/password flows locally, confirm session retrieval in server components, and deploy to Vercel or your preferred edge platform. Rotate secrets and enable production cookie flags.