Variant pattern of [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern]]. Use when your application has a canonical user id that is not Mongo _id — typically because every other collection in the app joins on a hex string userId field, not on _id. Implement the Adapter interface from @auth/core/adapters against existing mongoose models. ~120 lines.
userId field on every domain collection (bankAccounts.userId, transactions.userId, …).userId and be orphaned from the rest of the data model.auth() returns session.user.id === user.userId without translation layers.@auth/mongodb-adapter happily and _id is your canonical id. Stick with the off-the-shelf adapter (the [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern|sibling pattern]] covers it).createSession/getSessionAndUser/updateSession/deleteSession doubles the surface area; the off-the-shelf adapter handles them._id. Almost always cheaper than maintaining a custom adapter long-term, but rarely an option for established codebases.The adapter is a function returning an Adapter. Only OAuth + verification-token methods are needed for the JWT-strategy + Credentials + OAuth combo:
// src/lib/auth/mongoose-adapter.ts
import { Types } from "mongoose";
import type { Adapter, AdapterUser, AdapterAccount } from "@auth/core/adapters";
import { User } from "@/lib/models/userModel";
import { Account } from "@/lib/models/accountModel";
import { VerificationToken } from "@/lib/models/verificationTokenModel";
function toAdapterUser(u: LeanUser | null): AdapterUser | null {
if (!u) return null;
return {
id: u.userId, // ← key move: canonical app id, not _id
userId: u.userId,
email: u.email,
emailVerified: u.emailVerified ?? null,
name: u.name ?? null,
image: u.image ?? null,
// …module-augmented fields (role, newUser, …)
} as AdapterUser;
}
export function MongooseAdapter(): Adapter {
return {
async createUser(data) {
const doc = await User.create({
userId: new Types.ObjectId().toHexString(), // same scheme as Credentials createUser
email: data.email.toLowerCase(),
name: data.name ?? data.email,
emailVerified: data.emailVerified ?? null,
image: data.image ?? null,
newUser: true,
});
return toAdapterUser(leanFromDoc(doc))!;
},
async getUser(id) { /* User.findOne({ userId: id }) */ },
async getUserByEmail(e) { /* User.findOne({ email: e.toLowerCase() }) */ },
async getUserByAccount({ provider, providerAccountId }) {
const acct = await Account.findOne({ provider, providerAccountId });
if (!acct) return null;
return toAdapterUser(/* User.findOne({ userId: acct.userId }) */);
},
async updateUser({ id, ...rest }) {
const doc = await User.findOneAndUpdate({ userId: id }, { $set: rest }, { new: true });
return toAdapterUser(/* … */)!;
},
async deleteUser(id) {
await Account.deleteMany({ userId: id });
await User.deleteOne({ userId: id });
},
async linkAccount(account) {
// account.userId arrives already set to our app userId (because we returned
// it as AdapterUser.id earlier).
await Account.create(account);
return account as AdapterAccount;
},
async unlinkAccount({ provider, providerAccountId }) {
await Account.deleteOne({ provider, providerAccountId });
},
async createVerificationToken(token) { await VerificationToken.create(token); return token; },
async useVerificationToken({ identifier, token }) {
return await VerificationToken.findOneAndDelete({ identifier, token });
},
};
}
The accounts collection needs (provider, providerAccountId) unique. verificationTokens needs (identifier, token) unique + a TTL index on expires.
@auth/mongodb-adapter| Factor | Custom mongoose | @auth/mongodb-adapter |
|---|---|---|
| Lines of code to maintain | ~120 | ~0 |
| Bug surface (your code) | Yes — you own it | No — community-maintained |
| Schema control | Full (mongoose Schemas, indexes, validators, virtuals) | Limited (native driver writes BSON) |
| Canonical id alignment | App userId | Mongo _id |
| Mongoose pre-save hooks (e.g. password hashing) | Fire normally | Bypassed (adapter uses native driver) |
| Edge-runtime safety | Same constraints either way — keep adapter import in auth.ts, not auth.config.ts | Same |
auth.ts, not auth.config.ts. The mongoose connectDB() call pulls in the mongoose driver, which doesn't run on the edge runtime. Middleware uses NextAuth(authConfig) exclusively.updateUser does a blind $set spread. Auth.js writes whatever fields it wants. In practice that's emailVerified + OAuth profile fields; if you're paranoid, allow-list them explicitly.linkAccount trusts account.userId to be your canonical id. It IS — Auth.js sets it from the AdapterUser.id you returned — but there's no defensive check. Validate if you want to.getUserByEmail must lowercase the input to match how User stores email (assuming the rest of the app already normalises). Easy to forget.Adapter, not Required<Adapter> — Auth.js handles the absence). If you ever switch to database sessions, you have to add four more methods.emailVerified field: Auth.js sets this on OAuth sign-up from profile.email_verified. For Credentials users, you set it via your own email-verify flow. Both paths converge on the same field — keep them consistent.getUserByEmail returning the matching user