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
mongodbdriver
Assumption: you already have a basic MVC style project, or you are fine copying the structure from this guide.
The final backend will:
-
Connect to MongoDB twice:
- One connection through Mongoose for your own models.
- One connection through
MongoClientfor Better Auth.
-
Expose auth endpoints under
/api/auth/...(handled entirely by Better Auth). -
Expose a helper endpoint
/api/methat returns the currently logged in user. -
Expose your own routes, for example
/api/students, that still use Mongoose.
Flow at runtime:
- Client calls
/api/auth/sign-up/emailor/api/auth/sign-in/email. - Better Auth creates a user and sets a session cookie.
- Client calls
/api/meto know who is logged in. - Your protected routes can also check the session on the server.
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/dbnameKeep this handy for the .env step.
Create a new folder and initialize a Node project.
mkdir my-better-auth-backend
cd my-better-auth-backend
npm init -yInstall runtime dependencies:
npm install express cors dotenv mongoose mongodb better-auth colors tsxInstall dev dependencies for TypeScript:
npm install -D typescript ts-node-dev @types/node @types/express @types/corsCreate 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.
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.tsis the main entry point that starts Express.dbinit.tsconnects to MongoDB using Mongoose.auth.tsconfigures Better Auth with the MongoDB adapter.schemas/Student.tsholds a sample Mongoose model.routes/student_route.tsandcontrollers/student_controller.tsare 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.
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.
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_URIfrom.env. - Connects Mongoose to MongoDB.
- Logs a colored message when connected.
This connection is used by your Mongoose models, for example the Student model.
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.
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.
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;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" });
}
};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/studentsto list all students.POST /api/studentsto create a new student.
You can test these with Postman before touching 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 mongodbIn 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
MongoClientusing the sameMONGO_URI. mongodbAdaptertells Better Auth to store its collections in MongoDB.emailAndPassword.enabledturns on simple email and password sign up and sign in.trustedOriginstells 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.
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/emailPOST /api/auth/sign-in/emailGET /api/auth/get-session
-
express.json()comes after the Better Auth handler, so Better Auth can parse its own requests. -
/api/meusesauth.api.getSessionto read the user from the session cookie.
At this point, you can already test the backend without any frontend.
Start the server:
npm run devSend 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.
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.
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.
Finally, test your student routes:
-
GET http://localhost:8080/api/students -
POST http://localhost:8080/api/studentswith 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/meendpoint 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.
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.getSessionuses 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.userandsession.sessiononreqso 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.
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.
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/publicis always accessible. -
GET /api/students/protectedonly 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" }.
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/studentsrequires authentication. -
POST /api/studentsrequires authentication.
If you still want some routes to be public (for example /public), define them before router.use(requireAuth).
You can test the protection in two ways: with Postman or from a frontend.
-
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
-
-
Make sure Postman keeps cookies between requests (check the cookie tab).
-
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.
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
fetchto/api/students/withcredentials: "include"will succeed only when logged in.
The nice part is that your backend logic stays simple:
-
requireAuthchecks auth once. -
All routes behind it can trust that
req.useris present.
Aaand we're done! We'll be seeing how this all works with a React frontend in the next guide.