Better-TS-Stack
Modules

Auth Module

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

Overview

The Auth module adds user authentication to your project. The implementation is framework-specific:

StackImplementationRequires database
Express backendJWT + bcryptNo (uses in-memory user store by default)
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

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);

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" });
  }
}

The middleware is named requireAuth in the generated file. Import it by that name when protecting routes.


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"
  }
}

No additional scripts are added by the auth module.


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 anything like NEXT_PUBLIC_. These are server-side variables only.

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