When implementing authentication in a Next.J’s application using NextAuth, it’s common to have separate collections for User and UserProfile data. This separation enables user management to be more flexible, but it can also create complications, particularly when dealing with third-party authentication providers like Google.
Table of Contents
1. The Setup: Separate Collections
We have, two main collections, related to user, User Collection: Stores essential authentication data and in model/User.ts file we have
import mongoose, { Schema, Document } from "mongoose";
export interface UserAuth extends Document {
username: string;
email: string;
password: string;
role: string;
verifyCode: string;
verifyCodeExpiry: Date;
isVerified: boolean;
}
const UserAuthSchema: Schema<UserAuth> = new mongoose.Schema({
username: {
type: String,
required: [true, "Username is required"],
},
email: {
type: String,
required: [true, "Email is required"],
unique: true,
match: [/.+\@.+\..+/, "Please use a valid email address"],
},
password: {
type: String,
required: [true, "Password is required"],
},
role: {
type: String,
default: "user",
},
verifyCode: {
type: String,
required: [true, "Verify Code is required"],
},
verifyCodeExpiry: {
type: Date,
required: [true, "Verify Code Expiry is required"],
},
isVerified: {
type: Boolean,
default: false,
},
});
// Check if the model is already registered before registering it
const UserModel =
mongoose.models.User || mongoose.model<UserAuth>("User", UserAuthSchema);
export default UserModel;
UserProfile Collection: Stores additional user information and we have separate addresses collection as user might want different delivery address. Here is the userProfile collection.
import mongoose, { Schema, Document } from "mongoose";
export interface UserProfile extends Document {
userId: mongoose.Types.ObjectId;
email: string;
firstName?: string;
lastName?: string;
phoneNumber?: string;
dateOfBirth?: Date;
gender?: string;
bio?: string;
avatarUrl?: string;
lastUpdated: Date;
}
const UserProfileSchema: Schema<UserProfile> = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
},
firstName: { type: String, trim: true },
lastName: { type: String, trim: true },
phoneNumber: {
type: String,
validate: {
validator: function (v: string) {
return !v || /^\d{10}$/.test(v);
},
message: (props) => `${props.value} is not a valid phone number!`,
},
},
dateOfBirth: { type: Date },
gender: {
type: String,
enum: ["male", "female", "other", "prefer not to say"],
},
bio: { type: String, maxlength: 500 },
avatarUrl: { type: String },
lastUpdated: { type: Date, default: Date.now },
});
UserProfileSchema.pre("save", function (next) {
this.lastUpdated = new Date();
next();
});
const UserProfileModel =
mongoose.models.UserProfile ||
mongoose.model<UserProfile>("UserProfile", UserProfileSchema);
export default UserProfileModel;
2. The Challenges
The first issue is that when a user logs in via the Google provider, their record is not saved in the user collection. We need to fix this issue in the NextAuth callback signIn
option by checking if the user exists or not, and then creating a new user in the collection if necessary. We will also handle the user profile in this step. The solution will be added in Step 3 of the NextAuth options.
With this setup, we encountered several challenges due to our two login options: credential-based (email and password) and Google provider. When logging in with credentials and visiting the user account, we could successfully fetch the user profile. However, when logging in via the Google provider and accessing the account, it only showed ‘data not found’. Using NextAuth, we faced the following issues:
a) Profile Creation Timing: Ensuring a UserProfile is created immediately after a User is created during Google sign-in.
b) Profile Retrieval: Fetching the correct UserProfile after authentication, particularly with Google sign-in.
c) Data Consistency: Maintaining consistency between User and UserProfile data across different authentication methods.
d) Session Management: Properly populating the session with data from both User and UserProfile collections, regardless of the login method used
3. Step-by-Step Solutions
a) Timing of Profile Creation
We modified the NextAuth options to create a UserProfile right after creating a User. Here is the full code of the NextAuth options, including the signIn
callback to check the Google provider and determine if the user exists. If the user does not exist, a new user and UserProfile are created. In app/api/auth/[…nextauth]/options.ts file we have.
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import UserModel from "@/model/User";
import UserProfileModel from "@/model/UserProfile";
import { signInSchema } from "@/schemas/signInSchema";
import dbConnect from "@/lib/dbConnect";
async function authorize(credentials: any): Promise<any> {
await dbConnect();
const parsedCredentials = signInSchema.safeParse(credentials);
if (!parsedCredentials.success) {
throw new Error("Invalid credentials");
}
const { email, password } = parsedCredentials.data;
try {
const user = await UserModel.findOne({ email });
if (!user) {
throw new Error("No user found with this email");
}
// if (!user.isVerified) {
// throw new Error("Please verify your account before logging in");
// }
const isPasswordCorrect = await bcrypt.compare(password, user.password);
if (isPasswordCorrect) {
return user;
} else {
throw new Error("Incorrect password");
}
} catch (error) {
console.error("Login credential error:", error);
throw new Error("Login credential error");
}
}
export const authOptions: NextAuthOptions = {
providers: [
GoogleProvider({
clientId: process.env.AUTH_GOOGLE_ID as string,
clientSecret: process.env.AUTH_GOOGLE_SECRET as string,
}),
CredentialsProvider({
id: "credentials",
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials: any): Promise<any> {
return authorize(credentials);
},
}),
],
callbacks: {
async signIn({ user, account, profile }) {
if (account?.provider === "google") {
await dbConnect();
let existingUser = await UserModel.findOne({ email: user.email });
if (!existingUser) {
// Create new user
existingUser = new UserModel({
email: user.email,
username: user.email?.split("@")[0] || `user_${Date.now()}`,
isVerified: true,
role: "user",
// Set a random password for Google users
password: await bcrypt.hash(
Math.random().toString(36).slice(-8),
10
),
verifyCode: "GOOGLE_AUTH",
verifyCodeExpiry: new Date(),
});
await existingUser.save();
// Create corresponding user profile
const newUserProfile = new UserProfileModel({
userId: existingUser._id,
firstName: user.name?.split(" ")[0] || "",
lastName: user.name?.split(" ").slice(1).join(" ") || "",
avatarUrl: user.image,
});
await newUserProfile.save();
} else {
// Update existing user
existingUser.isVerified = true;
await existingUser.save();
// Update or create user profile
await UserProfileModel.findOneAndUpdate(
{ userId: existingUser._id },
{
$set: {
firstName: user.name?.split(" ")[0] || "",
lastName: user.name?.split(" ").slice(1).join(" ") || "",
avatarUrl: user.image,
email: user.email, // Add this line
},
},
{ upsert: true, new: true }
);
}
// Update the user object with _id, role, and username
//To avoid unuthorised CRUD operation of undefined _id on session and token
user._id = existingUser._id;
user.role = existingUser.role;
user.username = existingUser.username;
}
return true;
},
async redirect({ url, baseUrl }) {
// Always redirect to the home page after sign-in
return baseUrl;
},
async jwt({ token, user }) {
if (user) {
token._id = user._id?.toString();
token.isVerified = user.isVerified;
token.role = user.role;
token.username = user.username;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user._id = token._id as string;
session.user.role = token.role as string;
session.user.username = token.username as string;
}
return session;
},
},
session: {
strategy: "jwt",
maxAge: 60 * 60, // 1 hour
},
jwt: {
maxAge: 60 * 60, // 1 hour
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: "/auth/sign-in",
error: "/auth/error", // Add this line
},
};
b) Profile Retrieval
We updated the getUserProfile
function to fetch the profile using the User’s email, in lib/userProfileManager.ts and here it the code
import { getSession } from "next-auth/react";
import axios from "axios";
const getUserProfile = async () => {
console.log("getUserProfile function called");
try {
const session = await getSession();
console.log("Session data:", session);
if (!session || !session.user) {
console.log("No authenticated user in session");
return null;
}
const userId = session.user._id;
const userEmail = session.user.email;
if (!userId && !userEmail) {
console.log("No user identifier found in session");
return null;
}
console.log("Fetching user profile");
const response = await axios.get("/api/user-profile");
console.log("API response:", response.data);
if (response.data.userProfile) {
console.log(
"Returning existing user profile:",
response.data.userProfile
);
return response.data.userProfile;
}
console.log("No existing profile found, attempting to create one");
if (userEmail) {
const newProfileResponse = await axios.post("/api/user-profile", {
userId: userId,
email: userEmail,
firstName: session.user.name?.split(" ")[0] || "",
lastName: session.user.name?.split(" ").slice(1).join(" ") || "",
avatarUrl: session.user.image || "",
});
console.log("New profile created:", newProfileResponse.data.userProfile);
return newProfileResponse.data.userProfile;
}
console.log("Unable to create user profile");
return null;
} catch (error) {
console.error("Error in getUserProfile:", error);
return null;
}
};
export { getUserProfile };
And updated the API route to find the profile:
xport async function GET(req: NextRequest) {
try {
await dbConnect();
const session = await getServerSession(authOptions);
if (!session?.user?._id && !session?.user?.email) {
return NextResponse.json(
createApiResponse<undefined>(false, "Not authenticated", 401)
);
}
let userProfile;
if (session.user._id) {
userProfile = await UserProfileModel.findOne({
userId: session.user._id,
}).lean();
}
if (!userProfile && session.user.email) {
userProfile = await UserProfileModel.findOne({
email: session.user.email,
}).lean();
}
if (!userProfile) {
return NextResponse.json(
createApiResponse(
true,
"User profile not found, but can be created",
404
)
);
}
return NextResponse.json(
{
success: true,
message: "User profile retrieved successfully",
userProfile,
},
{ status: 200 }
);
} catch (error) {
console.error("Error retrieving user profile:", error);
return NextResponse.json(
createApiResponse<undefined>(false, "Internal server error", 500)
);
}
}
c) Account Component Setup
In the user account or profile page, we implemented the following setup to manage user profile data:
const Account = () => {
const [isEditing, setIsEditing] = useState(false);
const { data: session, status } = useSession();
const router = useRouter();
const { toast } = useToast();
const {
data: userProfile,
error: profileError,
mutate: mutateProfile,
isLoading: isProfileLoading,
} = useSWR("userProfile", getUserProfile, {
revalidateOnFocus: false,
shouldRetryOnError: false,
onSuccess: (data) => console.log("SWR success, userProfile:", data),
onError: (error) => {
console.error("SWR Error:", error);
toast({
title: "Error",
description: "Failed to fetch user profile. Please try again.",
variant: "destructive",
});
},
});
// ... rest of the component
};
This setup utilizes the useSWR
hook to fetch and manage the user profile data. It provides:
- Automatic data fetching and caching
- Error handling with user-friendly toast notifications
- Loading state management
- The ability to manually trigger refetches with
mutateProfile
By using SWR, we ensure that the user profile data stays up-to-date and is efficiently managed on the client side, addressing our challenges with profile retrieval and data consistency.
c) Data Consistency
We implemented a mechanism to sync data between User and UserProfile:
// In options.ts
callbacks: {
async session({ session, token }) {
if (session.user) {
const userProfile = await UserProfileModel.findOne({ email: session.user.email });
if (userProfile) {
session.user.firstName = userProfile.firstName;
session.user.lastName = userProfile.lastName;
// Add other fields as necessary
}
}
return session;
},
// ... other callbacks
}
d) Session Management
We ensured the session contains necessary data from both User and UserProfile:
// In options.ts
callbacks: {
async jwt({ token, user }) {
if (user) {
token.userId = user._id;
const userProfile = await UserProfileModel.findOne({ userId: user._id });
if (userProfile) {
token.firstName = userProfile.firstName;
token.lastName = userProfile.lastName;
}
}
return token;
},
// ... other callbacks
}
Key Concepts Explained
Separate Collections Design
Dividing the User and UserProfile collections offers several advantages:
- Enhanced data organization
- Flexible schema evolution
- Improved performance for authentication-related queries
However, this approach necessitates careful management to maintain data synchronization and ensure efficient retrieval.
NextAuth Callbacks
NextAuth provides several callbacks that allow customization of the authentication process:
- signIn: Triggered when a user signs in
- jwt: Triggered whenever a JWT is created or updated
- session: Triggered whenever a session is verified
These callbacks are essential for handling our separate collections setup.
Best Practices and Lessons Learned
- Consistent Identifiers: Utilize email as a consistent identifier across both collections to simplify data management.
- Eager Profile Creation: Generate the UserProfile immediately after creating the User to ensure data consistency.
- Efficient Data Fetching: Retrieve UserProfile data during session creation to reduce additional database queries.
- Error Handling: Implement robust error handling, especially for scenarios where a User exists but a UserProfile does not.
- Type Safety: Utilize TypeScript to ensure type safety throughout your authentication and profile management code.
Conclusion: Managing separate User and UserProfile collections with NextAuth requires careful consideration of data flow and timing. By leveraging NextAuth callbacks and ensuring proper data synchronization, a robust and flexible authentication system can be created. Always weigh the trade-offs between data normalization and query performance, and tailor your approach based on the specific needs of your application.