Skip to content

Instantly share code, notes, and snippets.

@ReaganS94
Created November 18, 2025 16:35
Show Gist options
  • Select an option

  • Save ReaganS94/0fd2e09d07bf8b598d1423cc9827a158 to your computer and use it in GitHub Desktop.

Select an option

Save ReaganS94/0fd2e09d07bf8b598d1423cc9827a158 to your computer and use it in GitHub Desktop.

Better Auth + Express + Mongoose backend guide (BE only)

This guide shows how to build a TypeScript backend that uses:

  • Express for HTTP routes
  • Mongoose for your own data (for example students)
  • Better Auth for authentication
  • MongoDB adapter for Better Auth, using the native mongodb driver

Assumption: you already have a basic MVC style project, or you are fine copying the structure from this guide.


1. What you are building

The final backend will:

  • Connect to MongoDB twice:

    • One connection through Mongoose for your own models.
    • One connection through MongoClient for Better Auth.
  • Expose auth endpoints under /api/auth/... (handled entirely by Better Auth).

  • Expose a helper endpoint /api/me that returns the currently logged in user.

  • Expose your own routes, for example /api/students, that still use Mongoose.

Flow at runtime:

  1. Client calls /api/auth/sign-up/email or /api/auth/sign-in/email.
  2. Better Auth creates a user and sets a session cookie.
  3. Client calls /api/me to know who is logged in.
  4. Your protected routes can also check the session on the server.

2. Prerequisites

Before you start, make sure you have:

  • Node.js installed (v18 or newer is recommended).
  • MongoDB database you can connect to (local or cloud, for example MongoDB Atlas).
  • Basic understanding of TypeScript and Express.

You will also need a MongoDB connection string, something like:

MONGO_URI=mongodb+srv://user:password@cluster-url/dbname

Keep this handy for the .env step.


3. Project bootstrap

Create a new folder and initialize a Node project.

mkdir my-better-auth-backend
cd my-better-auth-backend
npm init -y

Install runtime dependencies:

npm install express cors dotenv mongoose mongodb better-auth colors tsx

Install dev dependencies for TypeScript:

npm install -D typescript ts-node-dev @types/node @types/express @types/cors

Create a basic tsconfig.json in the project root:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Update package.json to:

  • Mark the project as ESM.
  • Add scripts for dev and prod.

Example:

{
  "name": "my-better-auth-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

You can adjust script names if you already use different ones, but the important bit is "type": "module" and a dev script that runs src/server.ts with TypeScript.


4. Create the folder structure

Inside your project folder, create a src folder with the structure I have underneath:

src/
  auth.ts
  dbinit.ts
  server.ts
  controllers/
    student_controller.ts
  routes/
    student_route.ts
  schemas/
    Student.ts
  zod_schemas/
    student.ts   (optional for now, only if you want validation)

Short explanation of each file:

  • server.ts is the main entry point that starts Express.
  • dbinit.ts connects to MongoDB using Mongoose.
  • auth.ts configures Better Auth with the MongoDB adapter.
  • schemas/Student.ts holds a sample Mongoose model.
  • routes/student_route.ts and controllers/student_controller.ts are a simple MVC feature that proves Mongoose still works.

You can adapt the names to your existing project if you already have controllers and routes.


5. Configure environment variables

Create a .env file in the project root (next to package.json):

PORT=8080

MONGO_URI=your-mongodb-connection-string-here

# only if you want to specify a different db for auth, otherwise keep empty or delete 
MONGO_AUTH_DB=better_auth_db

BETTER_AUTH_SECRET=some-long-random-secret
BETTER_AUTH_URL=http://localhost:8080

FRONTEND_ORIGIN=http://localhost:5173

# Optional, only if you configure social providers later
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=

Explanation:

  • PORT: Express will run on this port.
  • MONGO_URI: used by both Mongoose and the MongoDB client.
  • MONGO_AUTH_DB: name of the database where Better Auth stores its collections.
  • BETTER_AUTH_SECRET: secret used by Better Auth to sign tokens.
  • BETTER_AUTH_URL: base URL of your backend.
  • FRONTEND_ORIGIN: origin allowed to use the API with cookies (for example your Vite dev server).
  • The social variables are placeholders and not required for the basic email and password flow.

Make sure .env is in .gitignore if you commit this project.


6. Mongoose connection (dbinit.ts)

src/dbinit.ts is responsible for connecting to MongoDB with Mongoose for your own data.

// src/dbinit.ts
import mongoose from "mongoose";
import "colors";

const connectDB = async (): Promise<void> => {
  try {
    const uri = process.env.MONGO_URI as string;
    if (!uri) {
      throw new Error("MONGO_URI missing in environment");
    }

    const conn = await mongoose.connect(uri);
    console.log(
      `MongoDB successfully connected: ${conn.connection.name}`.underline.cyan
    );
  } catch (error) {
    console.error("Mongo connection error", error);
  }
};

export default connectDB;

What this does:

  • Reads MONGO_URI from .env.
  • Connects Mongoose to MongoDB.
  • Logs a colored message when connected.

This connection is used by your Mongoose models, for example the Student model.


7. Basic Express server without auth

Start with a minimal src/server.ts that just connects Mongoose and exposes a simple route.

// src/server.ts
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import "colors";

import connectDB from "./dbinit";
import studentRouter from "./routes/student_route";

dotenv.config();

const app = express();
const port = Number(process.env.PORT) || 8080;

connectDB();

const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || "http://localhost:5173";

// Global CORS configuration
app.use(
  cors({
    origin: FRONTEND_ORIGIN,
    credentials: true,
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  })
);

// If the browser sends an OPTIONS preflight, answer it here
app.options("*", cors({ origin: FRONTEND_ORIGIN, credentials: true }));

// JSON body parser (Better Auth will be inserted before this later)
app.use(express.json());

app.get("/", (_req, res) => {
  res.send("Welcome to the base URL");
});

// Student routes (simple MVC example)
app.use("/api/students", studentRouter);

app.listen(port, () => {
  console.log(`Server listening on port ${port}`.bgGreen);
});

At this stage, you can already run npm run dev to check that the server starts and that CORS is working.


8. Add a sample Student model and route

This is not required for Better Auth, but it proves that your domain data and Mongoose still work. Also, we will need it later when we'll protect a route via middleware.

8.1 Student Mongoose schema

Create src/schemas/Student.ts:

// src/schemas/Student.ts
import mongoose, { Schema, Document } from "mongoose";

export interface StudentDocument extends Document {
  name: string;
  email: string;
}

const StudentSchema = new Schema<StudentDocument>(
  {
    name: { type: String, required: true },
    email: { type: String, required: true, unique: true },
  },
  {
    timestamps: true,
  }
);

const Student = mongoose.model<StudentDocument>("Student", StudentSchema);

export default Student;

8.2 Student controller

Create src/controllers/student_controller.ts:

// src/controllers/student_controller.ts
import { Request, Response } from "express";
import Student from "../schemas/Student";

export const getAllStudents = async (_req: Request, res: Response) => {
  try {
    const students = await Student.find();
    res.json(students);
  } catch (err) {
    console.error("Error fetching students", err);
    res.status(500).json({ error: "Internal server error" });
  }
};

export const createStudent = async (req: Request, res: Response) => {
  try {
    const { name, email } = req.body;
    const student = await Student.create({ name, email });
    res.status(201).json(student);
  } catch (err) {
    console.error("Error creating student", err);
    res.status(500).json({ error: "Internal server error" });
  }
};

8.3 Student route

Create src/routes/student_route.ts:

// src/routes/student_route.ts
import { Router } from "express";
import { getAllStudents, createStudent } from "../controllers/student_controller";

const router = Router();

router.get("/", getAllStudents);
router.post("/", createStudent);

export default router;

You now have:

  • GET /api/students to list all students.
  • POST /api/students to create a new student.

You can test these with Postman before touching auth.


9. Time for Better Auth

Now it is time to add Better Auth with the MongoDB adapter.

If you did not install it earlier, install the packages:

npm install better-auth mongodb

9.1 Create auth.ts

In src/auth.ts add this:

// src/auth.ts
import dotenv from "dotenv";
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { MongoClient } from "mongodb";

dotenv.config();

const uri = process.env.MONGO_URI;
if (!uri) {
  throw new Error("MONGO_URI is missing in environment");
}

const client = new MongoClient(uri);
const dbName = process.env.MONGO_AUTH_DB;
const db = dbName ? client.db(dbName) : client.db();

export const auth = betterAuth({
  baseURL: process.env.BETTER_AUTH_URL, // for example http://localhost:8080

  database: mongodbAdapter(db, { client }),

  emailAndPassword: {
    enabled: true,
  },

  trustedOrigins: [
    process.env.FRONTEND_ORIGIN || "http://localhost:5173",
  ],

  // If you later add social providers, you can extend this config here.
});

Explanation:

  • It creates a separate MongoClient using the same MONGO_URI.
  • mongodbAdapter tells Better Auth to store its collections in MongoDB.
  • emailAndPassword.enabled turns on simple email and password sign up and sign in.
  • trustedOrigins tells Better Auth which frontend origins are allowed to talk to it.

This file does not use Mongoose at all. It uses the native MongoDB driver.


10. Wire Better Auth into Express

Now plug Better Auth into your server.ts.

Update src/server.ts to import auth and the helper from better-auth/node.

// src/server.ts
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import "colors";

import connectDB from "./dbinit";
import studentRouter from "./routes/student_route";
import { toNodeHandler, fromNodeHeaders } from "better-auth/node";
import { auth } from "./auth";

dotenv.config();

const app = express();
const port = Number(process.env.PORT) || 8080;

connectDB();

const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || "http://localhost:5173";

// 1) Global CORS for all routes (including /api/auth)
app.use(
  cors({
    origin: FRONTEND_ORIGIN,
    credentials: true,
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  })
);

// 2) Handle CORS preflight explicitly
app.options("*", cors({ origin: FRONTEND_ORIGIN, credentials: true }));

// 3) Better Auth handler on /api/auth
//    Important: this comes BEFORE express.json
app.use("/api/auth", toNodeHandler(auth));

// 4) Body parser for your own JSON routes
app.use(express.json());

// 5) Simple root route
app.get("/", (_req, res) => {
  res.send("Welcome to the base URL");
});

// 6) Helper route that returns the current user
app.get("/api/me", async (req, res) => {
  try {
    const session = await auth.api.getSession({
      headers: fromNodeHeaders(req.headers),
    });

    if (!session) {
      return res.status(401).json({ user: null });
    }

    return res.json({
      user: session.user,
      session: {
        id: session.session.id,
        expiresAt: session.session.expiresAt,
      },
    });
  } catch (err) {
    console.error("Error in /api/me", err);
    return res.status(500).json({ error: "Internal server error" });
  }
});

// 7) Your existing student routes
app.use("/api/students", studentRouter);

app.listen(port, () => {
  console.log(`Server listening on port ${port}`.bgGreen);
});

Key points:

  • CORS middleware runs before the Better Auth handler, so all responses get the correct CORS headers.

  • app.use("/api/auth", toNodeHandler(auth)) mounts all Better Auth routes under /api/auth. For example:

    • POST /api/auth/sign-up/email
    • POST /api/auth/sign-in/email
    • GET /api/auth/get-session
  • express.json() comes after the Better Auth handler, so Better Auth can parse its own requests.

  • /api/me uses auth.api.getSession to read the user from the session cookie.


11. Test the backend with Postman

At this point, you can already test the backend without any frontend.

Start the server:

npm run dev

11.1 Sign up with email and password

Send a POST request to:

POST http://localhost:8080/api/auth/sign-up/email
Content-Type: application/json

Body:

{
  "name": "Test User",
  "email": "test@example.com",
  "password": "supersecret123"
}

You should get a JSON response with a user object and a session. Postman will also show a cookie set by localhost:8080.

11.2 Sign in with email and password

POST http://localhost:8080/api/auth/sign-in/email
Content-Type: application/json

Body:

{
  "email": "test@example.com",
  "password": "supersecret123"
}

Again, you should get a user and session in the response and see the session cookie.

11.3 Check current user with /api/me

Now send:

GET http://localhost:8080/api/me

Make sure Postman sends cookies. If Postman is configured to keep cookies between requests, the GET /api/me call should return:

{
  "user": {
    "id": "...",
    "email": "test@example.com",
    "name": "Test User",
    "...": "..."
  },
  "session": {
    "id": "...",
    "expiresAt": "..."
  }
}

If the cookie is missing or invalid, you will get:

{ "user": null }

with status 401.

11.4 Confirm that your Mongoose routes still work

Finally, test your student routes:

  • GET http://localhost:8080/api/students

  • POST http://localhost:8080/api/students with JSON body like:

    {
      "name": "Dave",
      "email": "dave@example.com"
    }

This proves that Mongoose and Better Auth coexist without interfering with each other.

You now have a backend that:

  • Uses Mongoose for your own models.
  • Uses Better Auth with the MongoDB adapter for authentication.
  • Exposes an /api/me endpoint that your frontend can use.

The next logical step is to add a requireAuth middleware that calls auth.api.getSession and rejects unauthenticated reuquets. We'll then protect the students route with it.


12. Create the requireAuth middleware

Create a new folder and file:

src/
  middleware/
    requireAuth.ts

Inside src/middleware/requireAuth.ts, add the following code:

import { Request, Response, NextFunction } from "express";
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "../auth";

export async function requireAuth(
  req: Request,
  res: Response,
  next: NextFunction
) {
  try {
    // Ask Better Auth to read the session from cookies/headers
    const session = await auth.api.getSession({
      headers: fromNodeHeaders(req.headers),
    });

    // No session -> not authenticated
    if (!session) {
      return res.status(401).json({ error: "Not authenticated" });
    }

    // Attach user and session to the request object
    // For now we keep the type loose; later we can tighten this with TypeScript types
    (req as any).user = session.user;
    (req as any).authSession = session.session;

    // Continue to the next middleware or route handler
    next();
  } catch (err) {
    console.error("Error in requireAuth middleware", err);
    return res.status(500).json({ error: "Internal server error" });
  }
}

Step by step:

  • fromNodeHeaders(req.headers) converts Express headers into the shape Better Auth expects.

  • auth.api.getSession uses cookies and headers to determine if there is a valid session.

  • When there is no session, the middleware stops the request with a 401.

  • When there is a session, the middleware stores session.user and session.session on req so that route handlers can access them.

Note: using (req as any) is the simplest way for beginners. Later you can extend the Express Request type to have a proper user field.


13. Protect a route using requireAuth

You can protect any route by inserting requireAuth before the route handler.

There are two common ways to do this:

  • Protect a single route.

  • Protect all routes in a router.

Let's see both patterns.

13.1 Protect a single route

import { Router } from "express";
import {
  getAllStudents,
  createStudent,
} from "../controllers/student_controller.js";
import { requireAuth } from "../middleware/requireAuth.js";

const router = Router();

// Public route (no auth)
router.get("/public", (_req, res) => {
  res.json({ message: "This route is public" });
});

// Protected route (requires a valid Better Auth session)
router.get("/protected", requireAuth, (req, res) => {
  const user = (req as any).user;

  res.json({
    message: "You are allowed to see this because you are logged in.",
    user,
  });
});

// Existing routes for students (you can choose to protect these later)
router.get("/", getAllStudents);
router.post("/", createStudent);

export default router;

In this example:

  • GET /api/students/public is always accessible.

  • GET /api/students/protected only works if the request has a valid Better Auth session cookie.

If you call the protected route from a frontend that is signed in, it will return the user info. If you call it from Postman without the session cookie, you will get a 401 with { "error": "Not authenticated" }.

13.2 Protect all routes in a router

If you want all routes in student_route.ts to be protected, you can use router.use(requireAuth):

import { Router } from "express";
import {
  getAllStudents,
  createStudent,
} from "../controllers/student_controller.js";
import { requireAuth } from "../middleware/requireAuth.js";

const router = Router();

// Every route defined after this line is protected
router.use(requireAuth);

router.get("/", getAllStudents);
router.post("/", createStudent);

export default router;

Now:

  • GET /api/students requires authentication.

  • POST /api/students requires authentication.

If you still want some routes to be public (for example /public), define them before router.use(requireAuth).


14. Testing the protected route

You can test the protection in two ways: with Postman or from a frontend.

14.1 Test with Postman (manual)

  1. Sign up or sign in using the Better Auth endpoints:

    • POST http://localhost:8080/api/auth/sign-up/email

    • or POST http://localhost:8080/api/auth/sign-in/email

  2. Make sure Postman keeps cookies between requests (check the cookie tab).

  3. Call the protected route:

    • GET http://localhost:8080/api/students

If the cookie is present and valid, you should see the JSON message and user info.

If you delete the cookie (or use a different Postman tab with no cookies), the same request will return:

{ "error": "Not authenticated" }

with status 401.

14.2 Test from a frontend (later)

Later, when you connect a React frontend:

  • The frontend will log in using Better Auth (for example signIn.email).

  • The browser will store the session cookie.

  • A fetch to /api/students/ with credentials: "include" will succeed only when logged in.

The nice part is that your backend logic stays simple:

  • requireAuth checks auth once.

  • All routes behind it can trust that req.user is present.

Aaand we're done! We'll be seeing how this all works with a React frontend in the next guide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment