Better-TS-Stack
Modules

Auth Module

JWT authentication for Express and NestJS, Better Auth for Next.js and TanStack Start

Overview

The Auth module adds user authentication. Which implementation you get depends on the framework:

StackImplementationRequires database
Express backendJWT + bcryptNo (in-memory user store when no database is selected)
NestJS backendJWT + bcryptNo (in-memory user store when no database is selected)
Next.js full-stackBetter AuthYes - only prompted when a database is selected
TanStack full-stackBetter AuthYes - only prompted when a database is selected

For Next.js and TanStack Start projects, the auth prompt is automatically skipped if you chose none for the database type. Better Auth requires a database-backed adapter to function.


Express - JWT Auth

NestJS projects get the same endpoints and rules in src/auth/. See the backend docs.

Generated files

src/
|-- lib/
|   \-- jwt.ts               # JWT sign/verify utilities
|-- middleware/
|   \-- requireAuth.ts       # Bearer token validation middleware
|-- services/
|   \-- userStore.ts         # User lookup helper
|-- controllers/
|   \-- authController.ts    # Register/login request handlers
\-- routes/
    \-- auth.ts              # Router mounted at /auth

The auth router is registered in src/index.ts:

import authRouter from "./routes/auth";

app.use("/auth", authRouter);

Request bodies are validated with Zod. POST /auth/register trims and lowercases the email, requires it to be a valid address, and requires a password of 8 characters to 72 bytes (bcrypt ignores anything longer). Invalid or missing fields return 400, and an email that is already registered returns 409, including when two sign-ups for the same email arrive at the same time.


JWT utility - src/lib/jwt.ts

import jwt from "jsonwebtoken";

export interface AuthTokenPayload {
  userId: string;
}

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";

export function signToken(payload: AuthTokenPayload): string {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}

export function verifyToken(token: string): AuthTokenPayload {
  return jwt.verify(token, JWT_SECRET) as AuthTokenPayload;
}

Auth middleware - src/middleware/requireAuth.ts

import { NextFunction, Request, Response } from "express";

import { AuthTokenPayload, verifyToken } from "../lib/jwt";

export interface AuthenticatedRequest extends Request {
  user?: AuthTokenPayload;
}

export function requireAuth(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): void {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    res.status(401).json({ error: "Authorization header missing or invalid" });
    return;
  }

  const token = authHeader.substring("Bearer ".length);

  try {
    const payload = verifyToken(token);
    req.user = payload;
    next();
  } catch (error) {
    console.error("JWT verification failed:", error);
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

Environment variables added

JWT_SECRET=please-change-me
JWT_EXPIRES_IN=1h

Dependencies added

{
  "dependencies": {
    "bcrypt": "^6.0.0",
    "jsonwebtoken": "^9.0.3"
  },
  "devDependencies": {
    "@types/bcrypt": "^6.0.0",
    "@types/jsonwebtoken": "^9.0.10"
  }
}

Scripts

The auth module does not add any scripts.


Next.js - Better Auth

Generated files

lib/
|-- auth.ts                          # Better Auth server configuration
|-- auth-client.ts                   # createAuthClient() for client components
|-- auth-schema.ts                   # Zod schemas and auth error helper
components/
\-- auth/
    |-- sign-in-form.tsx             # Client login form
    |-- sign-up-form.tsx             # Client signup form
    \-- sign-out-button.tsx          # Client sign-out action
app/
|-- sign-in/page.tsx                 # Session-aware login route
|-- sign-up/page.tsx                 # Session-aware signup route
|-- dashboard/page.tsx               # Protected example page
\-- api/auth/[...all]/route.ts       # Better Auth route handler

The adapter used in lib/auth.ts is determined by your ORM selection:

  • Prisma -> prismaAdapter from better-auth/adapters/prisma
  • Drizzle -> drizzleAdapter from better-auth/adapters/drizzle
  • MongoDB -> mongodbAdapter from better-auth/adapters/mongodb

Better Auth configuration - lib/auth.ts

import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { prismaAdapter } from "better-auth/adapters/prisma";
import client from "./prisma";

export const auth = betterAuth({
  database: prismaAdapter(client, { provider: "postgresql" }),
  emailAndPassword: { enabled: true },
  plugins: [nextCookies()],
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

Route handler - app/api/auth/[...all]/route.ts

import { toNextJsHandler } from "better-auth/next-js";

import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);

Client helper - lib/auth-client.ts

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

export const authClient = createAuthClient();

Usage in a client component:

const result = await authClient.signIn.email({
  email: "[email protected]",
  password: "password",
});

if (result.error) {
  console.error(result.error);
}

Generated auth routes

  • /sign-in redirects to /dashboard when the user already has a session
  • /sign-up redirects to /dashboard when the user already has a session
  • /dashboard redirects to /sign-in when no session exists

The generated forms use React Hook Form + Zod for field validation and Better Auth client helpers for the submit actions.

Schema changes

  • Prisma + Better Auth: the generated prisma/schema.prisma includes Better Auth core tables.
  • Drizzle + Better Auth: the generated lib/schema.ts contains Better Auth core tables instead of a sample table.
  • MongoDB + Better Auth: authentication uses the native MongoDB adapter and its own collections.

Environment variables added

BETTER_AUTH_SECRET=please-change-me-to-a-random-string
BETTER_AUTH_URL=http://localhost:3000

Do not prefix BETTER_AUTH_SECRET or BETTER_AUTH_URL with NEXT_PUBLIC_. These are server-side variables only.

Dependencies added

{
  "dependencies": {
    "@hookform/resolvers": "^5.9.1",
    "better-auth": "^1.7.3",
    "mongodb": "^7.6.0",
    "react-hook-form": "^7.87.0",
    "zod": "^4.5.4"
  }
}

TanStack Start - Better Auth

For TanStack Start, sessions use the tanstackStartCookies() plugin and server functions (createServerFn) instead of Next.js route handlers.

Generated files

src/
|-- lib/
|   |-- auth.ts                    # Better Auth server configuration
|   |-- auth-client.ts             # createAuthClient() for client components
|   |-- auth-functions.ts          # getSession() server function
|   \-- auth-schema.ts             # Zod schemas and auth error helper
|-- components/
|   \-- auth/
|       |-- sign-in-form.tsx       # Client login form
|       |-- sign-up-form.tsx       # Client signup form
|       \-- sign-out-button.tsx    # Client sign-out action
\-- routes/
    |-- sign-in.tsx                # Session-aware login route
    |-- sign-up.tsx                # Session-aware signup route
    |-- dashboard.tsx              # Protected example page
    \-- api/auth/$.ts              # Better Auth route handler

The adapter used in src/lib/auth.ts is determined by your ORM selection:

  • Prisma -> prismaAdapter from better-auth/adapters/prisma
  • Drizzle -> drizzleAdapter from better-auth/adapters/drizzle
  • MongoDB -> mongodbAdapter from better-auth/adapters/mongodb

Better Auth configuration - src/lib/auth.ts

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";

import { db } from "./db";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  emailAndPassword: { enabled: true },
  plugins: [tanstackStartCookies()],
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

Auth route handler - src/routes/api/auth/$.ts

import { createFileRoute } from "@tanstack/react-router";

import { auth } from "../../../lib/auth";

export const Route = createFileRoute("/api/auth/$")({
  server: {
    handlers: {
      GET: ({ request }) => auth.handler(request),
      POST: ({ request }) => auth.handler(request),
    },
  },
});

Session server function - src/lib/auth-functions.ts

import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";

import { auth } from "./auth";

export const getSession = createServerFn({ method: "GET" }).handler(
  async () => {
    return await auth.api.getSession({
      headers: getRequestHeaders(),
    });
  }
);

Protected routes call auth.api.getSession in beforeLoad and redirect({ to: "/sign-in" }) when no session exists. The router is wired with setupRouterSsrQueryIntegration in src/router.tsx.

Schema changes

  • Prisma + Better Auth: the generated prisma/schema.prisma includes Better Auth core tables.
  • Drizzle + Better Auth: the generated src/lib/schema.ts contains Better Auth core tables instead of a sample table.
  • MongoDB + Better Auth: authentication uses the native MongoDB adapter and its own collections.

Environment variables added

BETTER_AUTH_SECRET=please-change-me-to-a-random-string
BETTER_AUTH_URL=http://localhost:3000

Do not prefix BETTER_AUTH_SECRET or BETTER_AUTH_URL with VITE_. These are server-side variables only, and a VITE_ prefix exposes them to the browser bundle.

Dependencies added

{
  "dependencies": {
    "@hookform/resolvers": "^5.9.1",
    "better-auth": "^1.7.3",
    "dotenv": "^17.4.2",
    "mongodb": "^7.6.0",
    "react-hook-form": "^7.87.0",
    "zod": "^4.5.4"
  }
}

Security recommendations

  1. Set JWT_SECRET or BETTER_AUTH_SECRET to a long, random string before deploying
  2. Keep tokens short-lived (JWT_EXPIRES_IN=1h is the generated default)
  3. Use HTTPS in production - never send tokens over plain HTTP
  4. Add rate limiting to auth endpoints to prevent brute-force attacks
  5. Never log or store plain-text passwords

On this page