DEV Community

Cover image for Better Auth Implementation Guide: Complete Authentication Setup for Next.js and Node.js
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

Better Auth Implementation Guide: Complete Authentication Setup for Next.js and Node.js

Authentication is one of those features that every application needs, but building it from scratch is time-consuming and error-prone. I've tried many authentication solutions over the years, and Better Auth stands out for its simplicity, type safety, and flexibility. It handles sessions, password hashing, and all the edge cases automatically, while giving you full control over the implementation.

Better Auth is a modern, type-safe authentication library that simplifies user authentication in Node.js and React applications. In this guide, we'll learn how to implement complete authentication with email/password, session management, and protected routes.

πŸ“– Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.

What is Better Auth?

Better Auth is a modern authentication library for Node.js and React applications. It provides:

  • Type-safe authentication - Full TypeScript support with autocomplete
  • Email/password authentication - Built-in email and password auth
  • Session management - Automatic session handling with cookies
  • Multiple database adapters - Drizzle ORM, Prisma, and more
  • Custom password hashing - Use scrypt, bcrypt, or your own hashing
  • OAuth support - Integrate with social providers
  • Protected routes - Easy route protection for both client and server
  • Production-ready - Designed for SaaS applications

Installation

First, let's install Better Auth and a database adapter:

npm install better-auth
npm install better-auth/adapters/drizzle
Enter fullscreen mode Exit fullscreen mode

For Drizzle ORM (used in this example):

npm install drizzle-orm drizzle-kit
Enter fullscreen mode Exit fullscreen mode

Backend Setup: Auth Configuration

Configure Better Auth with your database adapter and custom password hashing:

import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db/index.js';
import * as schema from './db/schema.js';
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
import { promisify } from 'util';

const scryptAsync = promisify(scrypt);

// Custom password hashing with scrypt
async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16).toString('hex');
  const normalizedPassword = password.normalize('NFKC');
  const derivedKey = (await scryptAsync(normalizedPassword, salt, 64, {
    N: 16384,
    r: 8,
    p: 1,
  })) as Buffer;
  return `${salt}:${derivedKey.toString('hex')}`;
}

async function verifyPassword(data: { hash: string; password: string }): Promise<boolean> {
  const [salt, storedKey] = data.hash.split(':');
  if (!salt || !storedKey) return false;

  const normalizedPassword = data.password.normalize('NFKC');
  const derivedKey = (await scryptAsync(normalizedPassword, salt, 64, {
    N: 16384,
    r: 8,
    p: 1,
  })) as Buffer;

  const storedKeyBuffer = Buffer.from(storedKey, 'hex');
  return timingSafeEqual(derivedKey, storedKeyBuffer);
}

export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET!,
  baseURL: process.env.BETTER_AUTH_URL!,
  basePath: '/api/auth',
  database: drizzleAdapter(db, {
    provider: 'sqlite',
    schema: {
      user: schema.users,
      session: schema.sessions,
      account: schema.accounts,
      verification: schema.verifications,
    },
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false,
    password: {
      hash: hashPassword,
      verify: verifyPassword,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // 1 day
  },
  trustedOrigins: [process.env.FRONTEND_URL!],
});
Enter fullscreen mode Exit fullscreen mode

Key Configuration Options

  • secret - A strong, random secret for signing tokens
  • baseURL - Your backend URL
  • basePath - Path where auth routes will be mounted
  • database - Database adapter configuration
  • emailAndPassword - Enable email/password authentication
  • session - Session expiration and update settings
  • trustedOrigins - CORS origins that can access auth endpoints

Express.js Integration

Mount Better Auth routes in your Express server:

import express from 'express';
import { toNodeHandler } from 'better-auth/node';
import { auth } from './lib/auth.js';
import cookieParser from 'cookie-parser';

const app = express();

app.use(express.json());
app.use(cookieParser());

// Mount Better Auth routes
app.all('/api/auth/*', toNodeHandler(auth));

app.listen(3001, () => {
  console.log('Server running on http://localhost:3001');
});
Enter fullscreen mode Exit fullscreen mode

The toNodeHandler function converts Better Auth's handler to work with Express.js, handling all authentication endpoints automatically.

Authentication Middleware

Create middleware to protect routes:

import { Request, Response, NextFunction } from 'express';
import { auth } from './lib/auth.js';

export interface AuthRequest extends Request {
  user?: {
    id: string;
    email: string;
    name?: string;
    role?: string;
  };
}

export async function requireAuth(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const token = req.headers.authorization?.startsWith('Bearer ')
      ? req.headers.authorization.substring(7)
      : req.cookies?.['better-auth.session_token'];

    if (!token) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    // Verify session
    const session = await auth.api.getSession({
      headers: {
        cookie: `better-auth.session_token=${token}`,
      } as unknown as Headers,
    });

    if (!session || !session.user) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    req.user = {
      id: session.user.id,
      email: session.user.email,
      name: session.user.name || undefined,
    };

    next();
  } catch (error) {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

// Use in routes
router.get('/protected', requireAuth, (req: AuthRequest, res) => {
  res.json({ message: 'Protected route', user: req.user });
});
Enter fullscreen mode Exit fullscreen mode

React Client Setup

Create the auth client for React:

import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
  basePath: '/api/auth',
});

export const { signIn, signUp, signOut, useSession } = authClient;
Enter fullscreen mode Exit fullscreen mode

Auth Context Provider

Create a context to share auth state across your app:

'use client';

import { createContext, useContext, ReactNode } from 'react';
import { useSession } from '@/lib/auth-client';

interface AuthContextType {
  user: { id: string; email: string; name?: string } | null;
  isLoading: boolean;
  isAuthenticated: boolean;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: ReactNode }) {
  const { data: session, isPending } = useSession();

  const value: AuthContextType = {
    user: session?.user || null,
    isLoading: isPending,
    isAuthenticated: !!session?.user,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

Wrap your app with the provider:

// app/layout.tsx or _app.tsx
import { AuthProvider } from '@/contexts/AuthContext';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AuthProvider>
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Login Page

Implement login with Better Auth:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn } from '@/lib/auth-client';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const result = await signIn.email({
        email,
        password,
      });

      if (result.error) {
        setError(result.error.message || 'Login failed');
        setIsLoading(false);
        return;
      }

      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'An error occurred');
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Registration Page

Implement user registration:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signUp } from '@/lib/auth-client';

export default function RegisterPage() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setIsLoading(true);

    try {
      const result = await signUp.email({
        email,
        password,
        name,
      });

      if (result.error) {
        setError(result.error.message || 'Registration failed');
        setIsLoading(false);
        return;
      }

      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'An error occurred');
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
        required
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      {error && <div className="error">{error}</div>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Creating account...' : 'Create account'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Protected Route Component

Create a component to protect routes:

'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push('/login');
    }
  }, [isAuthenticated, isLoading, router]);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (!isAuthenticated) {
    return null;
  }

  return <>{children}</>;
}

// Usage
export default function DashboardPage() {
  return (
    <ProtectedRoute>
      <div>Protected content</div>
    </ProtectedRoute>
  );
}
Enter fullscreen mode Exit fullscreen mode

Sign Out

Implement sign out functionality:

'use client';

import { signOut } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';

function LogoutButton() {
  const router = useRouter();

  const handleSignOut = async () => {
    await signOut();
    router.push('/login');
  };

  return <button onClick={handleSignOut}>Sign Out</button>;
}
Enter fullscreen mode Exit fullscreen mode

Database Schema

Required database tables for Better Auth (using Drizzle ORM):

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

// Users table
export const users = sqliteTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  emailVerified: integer('email_verified', { mode: 'boolean' }).default(false),
  image: text('image'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

// Sessions table
export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  token: text('token').notNull().unique(),
  userId: text('user_id').notNull().references(() => users.id),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

// Accounts table (for OAuth providers)
export const accounts = sqliteTable('accounts', {
  id: text('id').primaryKey(),
  accountId: text('account_id').notNull(),
  providerId: text('provider_id').notNull(),
  userId: text('user_id').notNull().references(() => users.id),
  accessToken: text('access_token'),
  refreshToken: text('refresh_token'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

// Verifications table (for email verification)
export const verifications = sqliteTable('verifications', {
  id: text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value: text('value').notNull(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }),
});
Enter fullscreen mode Exit fullscreen mode

Environment Variables

Configure your .env file:

BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001
Enter fullscreen mode Exit fullscreen mode

Important:

  • Generate a strong, random secret for BETTER_AUTH_SECRET
  • Never commit your .env file to version control
  • Use different secrets for development and production

Better Auth vs NextAuth

Feature Better Auth NextAuth
Type Safety Full TypeScript support Limited
Database Adapters Drizzle, Prisma, custom Limited options
Custom Password Hashing Fully customizable Limited
Session Management Automatic with cookies Automatic
React Integration Built-in hooks Built-in hooks
Express.js Support Native support Requires adapter
Production Ready Yes Yes

Better Auth provides more flexibility and type safety, making it ideal for modern TypeScript applications.

Best Practices

  1. Always use a strong, random secret - Generate secrets using openssl rand -base64 32
  2. Store secrets in environment variables - Never hardcode secrets in your code
  3. Use custom password hashing - Implement scrypt or bcrypt for better security control
  4. Set appropriate session expiration - Balance security and user experience
  5. Enable CORS with credentials - For cookie-based sessions to work across origins
  6. Use ProtectedRoute component - For client-side route protection
  7. Verify sessions in middleware - For server-side protection
  8. Handle loading states - During authentication checks
  9. Implement error handling - Gracefully handle authentication errors
  10. Use HTTPS in production - Essential for secure cookie transmission

Common Patterns

Server-Side Session Check

// In Next.js API route or server component
import { auth } from '@/lib/auth';

export async function GET(request: Request) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  return Response.json({ user: session.user });
}
Enter fullscreen mode Exit fullscreen mode

Client-Side Session Check

'use client';

import { useSession } from '@/lib/auth-client';

function UserProfile() {
  const { data: session, isPending } = useSession();

  if (isPending) return <div>Loading...</div>;
  if (!session?.user) return <div>Not authenticated</div>;

  return <div>Welcome, {session.user.email}!</div>;
}
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

Conclusion

Better Auth provides a clean, type-safe way to handle authentication in modern web applications. With minimal setup, you get email/password authentication, session management, and all the infrastructure you need. The library handles the complexity while giving you full control over the implementation details.

Key Takeaways:

  • Better Auth simplifies authentication with type-safe APIs
  • Custom password hashing gives you full control over security
  • Session management is handled automatically with cookies
  • Multiple database adapters support various ORMs
  • React integration is seamless with built-in hooks
  • Production-ready for SaaS applications
  • Express.js support makes it easy to integrate with Node.js backends

The key advantages I've found: type safety with TypeScript, flexible database adapters, custom password hashing support, and simple React integration. It's perfect for SaaS applications where you need reliable authentication without the overhead of managing sessions and tokens manually.


What's your experience with Better Auth? Share your tips and tricks in the comments below! πŸš€


πŸ’‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.

If you found this guide helpful, consider checking out my other articles on Node.js development and authentication best practices.

Top comments (0)