The Ideal Authorization in Next.js 16: Why BetterAuth Dethroned Auth.js v5, GDPR, and the End of the Middleware Era
In 2026, the phrase "we collect user data" makes any European client tense up. GDPR fines can reach 20 million euros, and businesses are no longer willing to trust their authorization to "black boxes" or poorly designed architectures. When building a SaaS for the EU market, data security isn't just a line in the technical requirements-it's your main competitive advantage that builds absolute trust.
For a long time, the de facto standard for Next.js was NextAuth (which eventually morphed into Auth.js). We all patiently typed pnpm add next-auth@beta, hoping the 5th version would finally exit its infinite beta, resolve type issues, and stop dragging along legacy baggage. But reality is harsh: for serious, strictly typed TypeScript projects, we need a next-gen tool.
And that tool is BetterAuth.
In this article, as a Fullstack Architect, I will show you how we build a rock-solid authorization system in Next.js 16 using BetterAuth, Prisma 7, and the new proxy.ts approach, leaving the outdated middleware.ts in the past.
Why BetterAuth is the new standard in 2026
The switch to BetterAuth isn't just following a trend; it's a strategic engineering decision. Here is why we chose it as the foundation for our platforms:
1. Absolute data control (GDPR Compliance)
For European clients, it is critical to know where and how their clients' data is stored. BetterAuth was created with transparency in mind. It doesn't hide database logic from you. Paired with Prisma 7, we have a clearly defined schema for users, sessions, and tokens. We deploy our projects in isolated Docker containers on our own European VPS (Ubuntu), and thanks to BetterAuth, we have 100% control over the lifecycle of every byte of information, without passing it to third-party cloud providers. This is exactly what European CEOs want to hear in the very first meeting.
2. Typing that doesn't make you suffer
If you've worked with Auth.js, you know the pain: trying to add a custom field (like role or companyId) to a session turned into a nightmare of overriding global interfaces. BetterAuth is written with a TypeScript-First approach. It automatically infers types from your configuration. Your code becomes strictly typed "out of the box," eliminating a whole class of bugs during the development phase.
3. Modularity instead of Monolith
BetterAuth works on a plugin principle. Need two-factor authentication (2FA)? Install a plugin. Need magic links? Install a plugin. You aren't dragging megabytes of unused code into your bundle.
The End of the Middleware Era: Meet proxy.ts
The biggest architectural shift in Next.js 16 was moving away from the familiar middleware.ts in favor of proxy.ts.
Previously, we forced middleware to do things it wasn't designed for: checking JWT tokens on every request, executing complex redirects, and trying to make the Edge environment "play nice" with our database. This often led to performance issues, complexities in Docker configurations, and module conflicts.
proxy.ts changes the rules of the game:
Speed: It works at the routing layer before the request even touches your React app logic.
Security: An ideal place for BetterAuth integration. We can instantly check session cookies and block unauthorized requests to the admin panel or API without loading the main server thread.
Code Cleanliness: Route protection logic is now separated from rendering logic.
In the following sections, I will show you the practical code: how to configure prisma.schema for BetterAuth, how to initialize the client, and how to write an impenetrable proxy.ts to protect your pages.
Step 1: Preparing the Foundation with Prisma 7 - The Database as a Fortress
When we talk about security and GDPR, the database is our main repository-our fortress. We can no longer afford "magic" tables created under the hood that are too scary to touch. Since we deploy our platforms in isolated Docker containers on our own Ubuntu VPS, we need absolute infrastructure predictability.
BetterAuth requires four basic models: User, Session, Account, and Verification. But the beauty of this framework is its perfect extensibility. We don't need to create additional tables like UserProfile or write complex JOIN queries. We simply take the base and "supercharge" it to fit our business requirements.
Here is what our schema.prisma looks like. Note how elegantly we weave our SaaS-specific fields directly into the base authorization model:
Фрагмент коду
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==============================
// BETTER AUTH: ADMIN & USER
// ==============================
model User {
id String @id
name String?
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 🟢 CUSTOM FIELDS (Extending the base model)
username String? @unique
bio String? @db.Text
website String?
linkedin String?
role String @default("USER")
isActive Boolean @default(true)
// 🟢 RELATIONS WITH OTHER PROJECT ENTITIES
accounts Account[]
sessions Session[]
softwareComments SoftwareComment[]
blogComments BlogComment[]
techStack UserTechStack[]
unitEconomicsSnapshots UnitEconomicsSnapshot[]
compareHistory CompareHistory[]
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[]
addedWorkspaceSoftware WorkspaceTechStack[]
workspaceVotes WorkspaceVote[]
@@map("users")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String? @db.Text
refreshToken String? @db.Text
idToken String? @db.Text
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("accounts")
}
model Verification {
id String @id
identifier String
value String @unique
expiresAt DateTime
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@map("verifications")
}
What makes this schema Enterprise-grade?
Strict Identifiers (String @id): Unlike older approaches using
@default(autoincrement()), we use string-based IDs here. BetterAuth generates cryptographically secure identifiers (nano-id or uuid) itself, which is perfect for PostgreSQL scaling and migrating data between environments.User Extension (Custom Fields): We added the
rolefield (for access control),isActive(for soft-blocking users without deleting data, a key GDPR requirement), and social media links. Later, when configuring BetterAuth, we will make TypeScript recognize these fields directly within thesession.userobject.Deep Relational Connections: Our
Userisn't just an isolated record for logging into the site. It’s the core of the platform. It is immediately linked to comments (BlogComment), workspaces (Workspace), and financial calculations (UnitEconomicsSnapshot). TheCascadedeletion in sessions and accounts ensures the database doesn't get cluttered with "dead" tokens when a user is deleted.
After updating the schema, don't forget to apply migrations and generate the client:
Bash
pnpm prisma migrate dev --name init_better_auth
pnpm prisma generate
Now that our database is ready and strictly typed, we can move to the heart of the system-configuring auth.ts.
Step 2: auth.ts - The Heart of Our Security and the End of Auth.js "Workarounds"
If you've ever tried to implement simultaneous email or username login in Auth.js (ex-NextAuth), along with user blocking status, you remember the pain. You had to write your own Credentials providers, manually handle sessions in jwt and session callbacks, and fight TypeScript constantly complaining about the missing role field.
BetterAuth solves this at the architectural level using plugins and hooks. We get full control over the request lifecycle. Furthermore, because we deploy on our own Ubuntu VPS (via Docker), it’s critical to have a "backdoor" for the Master Admin via environment variables-in case the database was just initialized and is still empty.
Here is our ready-to-use src/auth.ts config. Note how elegantly we solve 4 tasks at once:
Strict typing of custom fields (roles, status).
Login via both Email and Username.
User blocking (
isActive).Automatic Telegram notifications for new registrations.
TypeScript
// src/auth.ts
import bcrypt from 'bcryptjs'
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { prisma } from '@/lib/prisma'
import { sendNewUserNotification } from '@/lib/telegram/telegramBot'
// 🟢 Typing for the hook body
interface SignInBody {
email?: string
password?: string
}
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
password: {
hash: async (password) => await bcrypt.hash(password, 10),
verify: async ({ hash, password }) => await bcrypt.compare(password, hash),
},
},
// 🟢 Removed the emailVerification block. We'll send emails manually
// so Next.js doesn't cut off the sending process (timeout issues)!
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
},
linkedin: {
clientId: process.env.LINKEDIN_CLIENT_ID ?? '',
clientSecret: process.env.LINKEDIN_CLIENT_SECRET ?? '',
},
},
user: {
// 🟢 BetterAuth magic: these fields automatically end up in the Session TypeScript interface
additionalFields: {
role: { type: 'string', defaultValue: 'USER' },
username: { type: 'string', required: false },
isActive: { type: 'boolean', defaultValue: true },
},
},
plugins: [
{
id: 'custom-credentials-enhancements',
hooks: {
before: [
{
// Intercept request BEFORE BetterAuth starts verifying hashes
matcher: (ctx) => ctx.path?.startsWith('/sign-in/email') || false,
handler: async (ctx) => {
const bodyData = ctx.body as SignInBody | undefined
if (!bodyData?.email) return
const inputLogin = String(bodyData.email)
const inputPassword = String(bodyData.password || '')
const adminUserEnv = process.env.ADMIN_USER ?? ''
const adminPassEnv = process.env.ADMIN_PASSWORD ?? ''
// 🟢 MASTER ADMIN BACKDOOR:
// Ideal for first deployment on VPS. If login/pass match .env,
// we create or update the admin "on the fly."
if (
adminUserEnv &&
adminPassEnv &&
inputLogin === adminUserEnv &&
inputPassword === adminPassEnv
) {
let adminUser = await prisma.user.findUnique({
where: { email: 'admin@upart.club' },
})
if (!adminUser) {
adminUser = await prisma.user.create({
data: {
id: 'master-admin',
name: 'Master Admin',
email: 'admin@upart.club',
role: 'SUPERADMIN',
username: 'admin',
emailVerified: true,
},
})
}
const passwordHash = await bcrypt.hash(adminPassEnv, 10)
const account = await prisma.account.findFirst({
where: { userId: adminUser.id, providerId: 'credential' },
})
if (!account) {
await prisma.account.create({
data: {
id: 'master-admin-account',
accountId: 'admin@upart.club',
providerId: 'credential',
userId: adminUser.id,
password: passwordHash,
},
})
} else {
const isMatch = await bcrypt.compare(adminPassEnv, account.password || '')
if (!isMatch) {
await prisma.account.update({
where: { id: account.id },
data: { password: passwordHash },
})
}
}
// Swap body so BetterAuth proceeds with the correct email
bodyData.email = 'admin@upart.club'
return { context: ctx }
}
// 🟢 LOGIN VIA USERNAME OR EMAIL
const user = await prisma.user.findFirst({
where: { OR: [{ email: inputLogin }, { username: inputLogin }] },
})
if (user) {
// 🟢 GDPR SOFT DELETE: Block access if account is deactivated
if (!user.isActive) {
throw new Error('Account is blocked.')
}
bodyData.email = user.email
return { context: ctx }
}
},
},
],
},
},
],
databaseHooks: {
user: {
create: {
after: async (user) => {
// 🟢 AUTO-GENERATE USERNAME
const usernameStr = typeof user.username === 'string' ? user.username : null
let finalUsername: string | null = usernameStr
if (user.email && !finalUsername) {
const baseUsername = user.email
.split('@')[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
let usernameExists = await prisma.user.findUnique({
where: { username: baseUsername },
})
let newUsername = baseUsername
while (usernameExists) {
newUsername = `${baseUsername}${Math.floor(Math.random() * 1000)}`
usernameExists = await prisma.user.findUnique({
where: { username: newUsername },
})
}
await prisma.user.update({
where: { id: user.id },
data: { username: newUsername },
})
finalUsername = newUsername
}
// 🟢 TELEGRAM REGISTRATION NOTIFICATION
// Send notification to platform owner only for verified accounts (Social)
if (user.email && user.emailVerified) {
await sendNewUserNotification({
name: user.name || null,
username: finalUsername,
email: user.email,
provider: 'Social Auth (Google / LinkedIn)',
})
}
},
},
},
},
})
Breakdown: What Makes This Code Unique?
"Out-of-the-Box" Typing (
additionalFields): In Auth.js, we would have had to create anext-auth.d.tsfile and extend theUserandSessioninterfaces. With BetterAuth, you simply declareadditionalFields. The library generates the types automatically, and anywhere in your frontend, a call tosession.user.rolewill be strictly typed.Request Interception (
hooks.before): This is my favorite pattern. The user enters their nickname in the "Email/Username" field. We intercept this request before BetterAuth tries to find them by email. We perform our own query to Prisma (OR: [{ email }, { username }]), find the actual user email, substitute it in the request object, and let it pass through. To the core authorization logic, it looks like a standard email login. Simple and ingenious.Database Hooks and Infrastructure Ties: After a user is successfully created (via Google or LinkedIn), the
databaseHookstrigger. Here, we not only ensure every user has a uniqueusername(via awhileloop) but also integrate directly with our business processes. ThesendNewUserNotificationcall fires a message to our Telegram bot. As a founder, you know about new customers on your SaaS platform instantly.
Now that the authorization core is configured, we have the most important task left: locking down access to private pages and the admin panel. For this, we'll use the modern proxy.ts, sending the old middleware.ts into retirement.
Step 3: proxy.ts (Middleware) - The Ultimate Edge Guard for Next.js
In Next.js 16, the middleware pattern has matured. We no longer force our React application to handle basic security checks, rate limiting, or SEO redirects. Instead, we intercept requests at the edge.
When a European B2B client asks, "How do you protect our admin panel from brute-force attacks and handle legacy SEO links?" - you don't offer them a bunch of third-party plugins. You show them this architecture.
Here is our production-ready proxy.ts. It solves three massive enterprise challenges in one file:
API Rate Limiting: Protection against DDoS and spam.
Admin Access Control: Strict Role-Based Access Control (RBAC) specifically optimized for Docker and VPS environments.
Dynamic SEO Redirects: Blazing-fast 308 redirects via Redis + Prisma.
Take a close look at the Docker SSL NAT Loopback hack. This alone will save you days of debugging when deploying to an Ubuntu VPS under an Nginx reverse proxy.
TypeScript
// src/proxy.ts
import { type NextRequest, NextResponse } from 'next/server'
import { redis } from '@/lib/redis'
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
interface RateLimitInfo {
count: number
resetTime: number
}
declare global {
var rateLimitCleanerStarted: boolean | undefined
}
/* -------------------------------------------------------------------------- */
/* Rate Limiter Storage */
/* -------------------------------------------------------------------------- */
const rateLimitMap = new Map<string, RateLimitInfo>()
// Clear old IP addresses from memory once an hour to prevent memory leaks
if (!globalThis.rateLimitCleanerStarted) {
setInterval(
() => {
const now = Date.now()
for (const [ip, data] of rateLimitMap.entries()) {
if (now > data.resetTime) {
rateLimitMap.delete(ip)
}
}
},
60 * 60 * 1000,
)
globalThis.rateLimitCleanerStarted = true
}
/* -------------------------------------------------------------------------- */
/* Middleware */
/* -------------------------------------------------------------------------- */
export default async function middleware(req: NextRequest) {
const { nextUrl } = req
const pathname = nextUrl.pathname
// --- 1. API RATE LIMITING (EXCLUDING AUTH) ---
// Exclude /api/auth so BetterAuth requests pass without delays
if (pathname.startsWith('/api') && !pathname.startsWith('/api/auth')) {
const ip =
req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'127.0.0.1'
const now = Date.now()
const limit = 50
const windowMs = 60 * 1000 // 1 minute
const userRate = rateLimitMap.get(ip)
if (!userRate || now > userRate.resetTime) {
rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs })
} else {
userRate.count++
if (userRate.count > limit) {
return new NextResponse(
JSON.stringify({
error: 'Too many requests. Please try again later.',
}),
{ status: 429, headers: { 'Content-Type': 'application/json' } },
)
}
}
}
// --- 2. ADMIN ACCESS CONTROL LOGIC ---
const isAdminLoginPage = pathname === '/admin/login'
const isAdminArea = pathname.startsWith('/admin') && !isAdminLoginPage
if (isAdminLoginPage || isAdminArea) {
// 🟢 The Docker Hack: Hardcode the local HTTP address of the Docker container!
// This bypasses the external Nginx and HTTPS, avoiding the dreaded SSL NAT Loopback error.
const localBaseUrl = 'http://127.0.0.1:3005'
// Request the session directly from the local server instance
const sessionRes = await fetch(`${localBaseUrl}/api/auth/get-session`, {
headers: { cookie: req.headers.get('cookie') || '' },
})
const sessionData = await sessionRes.json()
const isLoggedIn = !!sessionData?.session
const userRole = sessionData?.user?.role || 'GUEST'
const allowedAdminRoles = ['SUPERADMIN', 'ADMIN', 'EDITOR', 'MODERATOR']
const hasAdminAccess = allowedAdminRoles.includes(userRole)
// If an authorized admin tries to access the admin login page
if (isAdminLoginPage) {
if (isLoggedIn && hasAdminAccess) {
return NextResponse.redirect(new URL('/admin', nextUrl))
}
return NextResponse.next()
}
// If attempting to access the restricted admin panel
if (isAdminArea) {
if (!isLoggedIn) {
const url = new URL('/admin/login', nextUrl)
url.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(url)
}
if (!hasAdminAccess) {
// Kick regular users without admin rights back to the homepage
return NextResponse.redirect(new URL('/', nextUrl))
}
}
}
// --- 3. SEO 308 REDIRECTS (REDIS CACHE + PRISMA FALLBACK) ---
if (
!pathname.startsWith('/_next') &&
!pathname.startsWith('/api') &&
!pathname.startsWith('/admin')
) {
try {
const cacheKey = `redirect:${pathname}`
// Look for a redirect rule in the Redis cache
let targetUrl = await redis.get(cacheKey)
// If the cache is empty, query the DB via a lazy Prisma import
if (!targetUrl) {
const { prisma } = await import('@/lib/prisma')
const redirectRule = await prisma.redirect.findUnique({
where: { sourceUrl: pathname },
})
if (redirectRule) {
targetUrl = redirectRule.targetUrl
// Store it in the cache permanently
await redis.set(cacheKey, targetUrl)
}
}
// If a redirect rule matched, return a clean SEO 308 permanent redirect
if (targetUrl) {
return NextResponse.redirect(new URL(targetUrl, nextUrl), 308)
}
} catch (e) {
console.error('Redirect Logic Error:', e)
}
}
return NextResponse.next()
}
/* -------------------------------------------------------------------------- */
/* Config */
/* -------------------------------------------------------------------------- */
export const config = {
// Exclude system files, fonts, and static assets to save server resources
matcher: [
'/((?!_next/static|_next/image|favicon.ico|assets-admin|assets-main|uploads|.*\\..*).*)',
],
}
Architectural Breakdown:
When you present this infrastructure to a client, you highlight three specific architectural victories:
1. Solving the Docker SSL NAT Loopback Issue
In a production environment, your Next.js app sits inside a Docker container behind an Nginx reverse proxy (managed by panels like HestiaCP) which handles the SSL certificates. If your middleware tries to fetch https://yourdomain.com/api/auth/get-session from inside the container, the request often fails due to NAT loopback restrictions-the container doesn't know how to route out to the internet and back to itself securely.
By hardcoding the internal loopback address (http://127.0.0.1:3005), we bypass Nginx entirely. The middleware queries the Node.js server directly. It is lightning-fast, 100% secure (since it never leaves the server boundary), and totally immune to DNS or SSL errors.
2. Native Rate Limiting without Edge Dependencies
Many tutorials tell you to use Vercel KV or Upstash for rate limiting. But what if the client's GDPR policy mandates that all data must stay on their physical server in Germany? We implemented a highly efficient, self-cleaning Map in memory. It tracks IPs and blocks aggressive bots trying to hammer your API, maintaining strict data sovereignty.
3. Enterprise SEO with Redis and Lazy Imports
For large platforms, URLs change. Marketing teams need 301/308 redirects. Querying PostgreSQL on every single page load to check for a redirect is a performance killer. Here, we use a hybrid approach: we check a blazing-fast Redis cache first. If it's a miss, we dynamically import('@/lib/prisma') so we don't load the massive ORM into memory on every request. Once found, it's cached in Redis. Google gets its 308 Permanent Redirect instantly, preserving your SEO juice.
Step 7: Server Actions & API - Controlling Every Step of Authorization
We moved away from "automatic magic" where it compromises stability. For example, BetterAuth can send emails on its own, but in the context of Next.js (due to Serverless limits or long timeouts), it’s better to keep this process under your own control.
We created a separate layer of Server Actions that doesn't just register the user but also protects us from spam using Google reCAPTCHA v3.
API Handler (Gateway)
First, we open the gateway for BetterAuth. Everything is as concise as possible: one handler for all methods (GET/POST), which BetterAuth manages itself.
TypeScript
// src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/auth'
// toNextJsHandler automatically routes methods like GET, POST, etc.
export const { GET, POST } = toNextJsHandler(auth)
Registration: Manual Control for Maximum Reliability
In our register.ts, we don't just call signUp. We first verify the user for "humanity" via reCAPTCHA, then register them, and only after success do we generate a verification token and send the email ourselves. This guarantees that if the email doesn't arrive, we will know about it because we are explicitly awaiting the result.
TypeScript
// src/actions/register.ts
'use server'
import { headers } from 'next/headers'
import { auth } from '@/auth'
import { generateVerificationToken } from '@/lib/auth/tokens'
import { sendVerificationEmail } from '@/lib/mail'
import { prisma } from '@/lib/prisma'
export async function registerUser(formData: FormData) {
// 1. Bot protection (Honeypot + reCAPTCHA)
// ... (recaptcha logic)
try {
// 2. Register user via Better Auth API
await auth.api.signUpEmail({
body: { email, password, name, username, role: 'USER', isActive: true },
headers: await headers(),
})
// 3. Generate token manually
const verificationToken = await generateVerificationToken(email)
// 4. Send email manually with await to ensure delivery
await sendVerificationEmail(email, verificationToken.value, name || '')
return { success: 'Confirmation email sent!', needsVerification: true }
} catch (error) {
return { error: 'Registration failed.' }
}
}
Profile Security and Password Updates
The biggest mistake beginners make is updating the password hash in the User table. We do it the right way: the password hash is part of the Account entity. When a user changes their password, we verify the current one using bcrypt.compare and only then write the new hash to the corresponding account.id.
TypeScript
// src/actions/user-settings.ts
export async function updatePassword(formData: PasswordData): Promise<ActionResponse> {
// 1. Get session via auth.api
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user?.id) return { error: 'Unauthorized' }
// 2. Find the email provider account
const account = await prisma.account.findFirst({
where: { userId: session.user.id, providerId: 'email' },
})
if (!account?.password) return { error: 'Account does not use password auth.' }
// 3. Compare old password and hash the new one
const passwordsMatch = await bcrypt.compare(currentPassword, account.password)
if (!passwordsMatch) return { error: 'Current password incorrect.' }
const hashedPassword = await bcrypt.hash(newPassword, 10)
// 4. Update the Account table
await prisma.account.update({
where: { id: account.id },
data: { password: hashedPassword },
})
return { success: 'Password updated successfully!' }
}
Client-Side: Auth Client
On the frontend, we don't use "raw" fetch requests. We use authClient, created via createAuthClient. This gives us useSession, signIn, and signOut hooks that work like standard React hooks but have full support for the Next.js App Router under the hood.
TypeScript
// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
})
// Export hooks for use in components
export const { useSession, signIn, signOut } = authClient
Conclusion: Why This Approach Is an Investment in the Future
Writing your own authorization system is something developers often try to avoid. But when you are building a product for the B2B sector, especially in the EU market, you cannot afford to play roulette with middleware.ts or hope that the next version of a "legacy library" won't break your production.
Choosing BetterAuth, Prisma 7, and proxy.ts is our conscious step toward predictability and security.
What we achieved in the end:
Full Data Control (GDPR Ready): We don't just use a library; we integrate authorization into our business flow. We decide how and when to send emails, how to block users, and how to store sessions.
Architectural Purity: Switching to
proxy.tsisn't just a file rename. It’s moving to an infrastructure level where you control requests before they load your Node.js server. It’s faster, more reliable, and easier to debug.Developer "Zen": Thanks to Prisma 7 and BetterAuth’s approach to typing, we no longer spend hours trying to convince TypeScript that the
rolefield exists. It’s there, and it works.VPS/Docker Reliability: We solved one of the most painful deployment issues-SSL NAT Loopback-and did it elegantly without breaking the Nginx configuration.
Final Thought
Code is a tool. But architecture is how those tools work together so your business doesn't grind to a halt at 3 AM because a third-party auth service went down. We stopped relying on "magic" and started building a foundation that handles the load, meets security standards, and allows us to scale without pain.
Set this up once, test it, and your next B2B client from Europe won't even need to ask about security-they will see it in the architecture of your product.
Happy coding, and may your services run like clockwork!
If you have questions about implementing specific BetterAuth plugins or want to discuss your project's architecture-drop a comment or catch me on Telegram. Happy to share my experience.

Коментарі (0)
Leave a comment