Account Functionality

Used in this guide:
Next.js 15.3.4
Payload CMS

Introduction

Payload's authentication functionality is tied to its email features, allowing you to send emails for verification, password resets, and more. Payload provides default email templates, but you can customize them to match your brand. Email verification helps reduce spam accounts and ensures users can access the email address they use to authenticate.

For more info, visit: Payload Email Functionality




Requirement

Before you proceed you should know how to properly setup Payload in your existing Next.js project.

Visit: Add Payload to existing Next.js project

And : Integrate PostgreSQL with Payload

And : Define Separated Users Collection




1. Install the Nodemailer Email Adapter


First, install the Nodemailer adapter:

npm install @payloadcms/email-nodemailer nodemailer


2. Configure the Email Adapter in Payload Config

You need to pass an email adapter into the email property of your Payload Config. The Nodemailer adapter allows you to use various transports, including SMTP.

Here's how to modify your payload.config.ts to include the Nodemailer adapter with SMTP transport options. This will allow Payload to send authentication-related emails, like verification and password resets.

For more info about setting up SMTP, check out Nodemailer Setup Tutorial


root/payload.confit.ts
...
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
 
export default buildConfig({
  ...
  email: nodemailerAdapter({
    // These will be seen as the 'From' name and address in the sent emails
    defaultFromName: 'Your Website Name',
    defaultFromAddress: 'no-reply@yourdomain.com',
    
    // Configure the transport options for Nodemailer
    transportOptions: {
      host: process.env.SMTP_HOST, // Your SMTP server host (e.g., smtp.gmail.com)
      port: parseInt(process.env.SMTP_PORT || "587"), // 587 for TLS, 465 for SSL
      secure: process.env.SMTP_SECURE === 'true', // Set to true if port is 465, false for 587 or 25
      auth: {
        user: process.env.SMTP_USER, // Your SMTP username (email)
        pass: process.env.SMTP_PASS, // Your SMTP password
      },
    },
  }),
})

Your .env.local might look like this:

DATABASE_URI=postgresql://db_user:db_user_password@localhost:5433/db_database_name
PAYLOAD_SECRET=randomSecretString
 
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your_email@gmail.com
SMTP_PASS=zxcvbnmasdfghjkq


Tips

For a smooth developing flow, you can set autoLogin in your Payload as follows:

root/payload.config.ts
  admin: {
    autoLogin: process.env.NODE_ENV === "development"
      ? {
        email: "youremail@email.com",
        password: "yourpassword",
        prefillOnly: true,
      }
      : false
  },

For more info, visit: Payload Auto-Login




3. Enable and Customize Email Verification in Auth Collection

First let's create a helper function:

root/lib/utility.ts
export function getRootUrl() {
  if (typeof window !== 'undefined') return ''; // keep client requests relative
  const url = process.env.NODE_ENV === 'development'
    ? process.env.NEXT_PUBLIC_ROOT_URL_DEV
    : process.env.NEXT_PUBLIC_ROOT_URL_PROD;
  return url?.replace(/\/+$/, '') ?? '';
}

Make sure the NEXT_PUBLIC_ROOT_URL_PROD has no www (use apex domain). And when you use your paid domain name in production, makes sure to make apex domain (no www) a production and point all additional domains to apex in Vercel. Otherwise you will get CORS error because of different origin.


To enable email verification, use the auth.verify property on your Collection Config. Here's a refined version of the Users collection with updated verification URLs:

payload/collections/users.ts
const Users: CollectionConfig = ...
...
    auth: {
        depth: 1,
        tokenExpiration: 604800,
        maxLoginAttempts: 5,
        lockTime: 600,
        verify: {
            generateEmailHTML: ({ token, user }) => {
                const userName = user?.name ?? user?.email ?? "friend";
                const verificationURL = getRootUrl() + `/verify?token=${token}`;
                return `Hi ${userName}, please verify your email by clicking here: <a href="${verificationURL}">${verificationURL}</a>`;
            },
            generateEmailSubject: ({ user }) => {
                const userName = user?.name ?? "friend";
                return `Hi ${userName}, please verify your email for mysite.com`;
            },
        },
        forgotPassword: {
            generateEmailHTML: (args) => {
                const token = args?.token ?? "";
                const userName = args?.user?.name ?? args?.user?.email ?? "friend";
                const resetPasswordURL = getRootUrl() + `/reset-password?token=${token}`;
                return `
                    <div>
                        <h1>Reset your password</h1>
                        <p>Hi, ${userName}</p>
                        <p>Click the link below to reset your password.</p>
                        <p>
                            <a href="${resetPasswordURL}">Reset Password</a>
                        <p>
                    </div>
                `
            },
            generateEmailSubject: (args) => {
                const userName = args?.user?.name ?? args?.user?.email ?? "friend";
                return `Hi ${userName}, reset your password for mysite.com`
            }
        }
    },
...    

Since we just enabled verification system, you have to enter Payload admin to trigger it. Now, you'll see that you can't log in. That's because you're not verified. You can enter your database (with pgAdmin) directly and set the verify column to true.

For the full (and final) collection reference, visit: Users Collection




4. Create Profiles Collection

root/payload/collections/profiles.ts
import { CollectionConfig } from "payload";
 
const Profiles: CollectionConfig = {
    slug: "profiles",
    admin: {
        useAsTitle: "email"
    },
    fields: [
        {
            name: "email",
            label: "Email",
            type: "email",
            unique: true,
            required: true,
            admin: {
                readOnly: true,
            }
        },
        {
            name: "firstName",
            label: "First Name",
            type: "text",
            required: false,
        },
        {
            name: "lastName",
            label: "Last Name",
            type: "text",
            required: false,
        },
        {
            name: "profileImage",
            label: "Profile Image",
            type: "text",   // will change to upload later
            required: false,
        },
        {
            name: "gender",
            label: "Gender",
            type: "select",
            required: false,
            options: [
                {
                    label: "Male",
                    value: "Male",
                },
                {
                    label: "Female",
                    value: "Female",
                },
                {
                    label: "Unspecified",
                    value: "Unspecified",
                },
            ]
        },
        {
            name: "address1",
            label: "Address Line 1",
            type: "textarea",
            required: false,
        },
        {
            name: "address2",
            label: "Address Line 2",
            type: "textarea",
            required: false,
        },
        {
            name: "city",
            label: "City",
            type: "text",
            required: false,
        },
        {
            name: "state",
            label: "State/Province/Region",
            type: "text",
            required: false,
        },
        {
            name: "zipcode",
            label: "Zip/Postal Code",
            type: "number",
            required: false,
        },
        {
            name: "country",
            label: "Country",
            type: "text",
            required: false,
        }
    ]
}
 
export default Profiles;

Run dev, to update the type for Profiles collection. Don't forget to add Profiles collection to payload.config.ts

Turn off dev

Add Payload functions

root/payload/functions/utility.ts
import { FieldHook } from "payload";
 
export const populateName: FieldHook = async ({ data, value }) => {
    if (!value && data?.email) {
        return data.email.split("@")[0];
    }
    return value;
}

Update Users Collection

root/payload/collections/users.ts
import { getRootUrl } from "@/lib/utility";
import { CollectionConfig } from "payload";
import { populateName } from "../functions/utility";
 
const Users: CollectionConfig = {
    slug: "users",
    auth: {
        depth: 1,
        tokenExpiration: 604800,
        maxLoginAttempts: 5,
        lockTime: 600,
        verify: {
            generateEmailHTML: ({ token, user }) => {
                const userName = user?.name ?? user?.email ?? "friend";
                const verificationURL = getRootUrl() + `/verify?token=${token}`;
                return `Hi ${userName}, please verify your email by clicking here: <a href="${verificationURL}">${verificationURL}</a>`;
            },
            generateEmailSubject: ({ user }) => {
                const userName = user?.name ?? "friend";
                return `Hi ${userName}, please verify your email for mysite.com`;
            },
        },
        forgotPassword: {
            generateEmailHTML: (args) => {
                const token = args?.token ?? "";
                const userName = args?.user?.name ?? args?.user?.email ?? "friend";
                const resetPasswordURL = getRootUrl() + `/reset-password?token=${token}`;
                return `
                    <div>
                        <h1>Reset your password</h1>
                        <p>Hi, ${userName}</p>
                        <p>Click the link below to reset your password.</p>
                        <p>
                            <a href="${resetPasswordURL}">Reset Password</a>
                        <p>
                    </div>
                `
            },
            generateEmailSubject: (args) => {
                const userName = args?.user?.name ?? args?.user?.email ?? "friend";
                return `Hi ${userName}, reset your password for mysite.com`
            }
        }
    },
    admin: {
        useAsTitle: "name"
    },
    fields: [
        {
            name: "name",
            type: "text",
            required: false,
            hooks: {
                afterRead: [populateName],
                beforeChange: [populateName],
            }
        },
        {
            name: "role",
            type: "select",
            options: [
                "admin",
                "editor",
                "web-user"
            ],
            defaultValue: "web-user",
        },
        {
            name: "profile",
            type: "relationship",
            relationTo: "profiles",
            hasMany: false,
            admin: {
                readOnly: true,
            },
            access: {
                create: () => false,
                update: () => false,
            }
        }
    ]
}
 
export default Users;

Run dev and let it auto update fields. IMPORTANT: Give yourself the admin role.

Turn off dev

Add Access Control Functions:

root/payload/functions/accessControl.ts
import { PayloadRequest } from "payload";
 
export const isAdminUser = (req: PayloadRequest) => {
    if (req.user?.collection === "users" && req.user?.role === "admin") {
        return true;
    } else {
        return false;
    }
}
 
export const isStaffUser = (req: PayloadRequest) => {
    if (
        req.user?.collection === "users" &&
        (req.user?.role === "admin" || req.user?.role === "editor")
    ) {
        return true;
    } else {
        return false;
    }
}
 
export const isAdminAndSelf = (req: PayloadRequest) => {
    if (req.user?.collection === "users" && req.user?.role === "admin") {
        return true;
    } 
    
    if (req.user && req.user.collection === "users") {
        return {
            id: {
                equals: req.user.id,
            }
        }
    }
 
    else {
        return false;
    }
}

Update Users Collection with Profile Relationship Field and Access Control Functions

root/payload/collections/users.ts
import { getRootUrl } from "@/lib/utility";
import { CollectionAfterChangeHook, CollectionBeforeValidateHook, CollectionConfig, FieldHook } from "payload";
import { isAdminAndSelf, isAdminUser } from "../functions/accessControl";
import { populateName } from "../functions/utility";
 
const autoLinkProfile: FieldHook = async ({
    operation, value, previousValue, siblingData, req,
}) => {
    if (operation !== "create" && operation !== "update") return value ?? previousValue;
    if (value ?? previousValue) return value ?? previousValue;
 
    if (!siblingData?.email) {
        throw new Error("Cannot create profile: user email is missing");
    }
 
    try {
        const profile = await req.payload.create({
            collection: "profiles",
            data: {
                email: siblingData.email,
            }
        });
        return profile.id;
    } catch (error) {
        req.payload.logger.error(`Failed to create profile for ${siblingData.email}: 
            ${(error as Error).message}`)
        return previousValue ?? value ?? null;
    }
}
 
const attachProfileOnSignup: CollectionBeforeValidateHook = async ({
    operation, data, req
}) => {
    if (operation !== "create") return data;
    if (!data) {
        throw new Error("No data provided");
    }
 
    const email = data?.email;
    const firstName = data?.firstName;
    const lastName = data?.lastName;
    
    if (!email) return data;
 
    const existing = await req.payload.find({
        collection: "profiles",
        where: { email: { equals: email }},
        limit: 1,
    });
 
    let profileId: string | number;
 
    if (existing.docs.length) {
        profileId = existing.docs[0].id;
        await req.payload.update({
            collection: "profiles",
            id: profileId,
            data: {
                firstName: firstName,
                lastName: lastName,
            }
        })
    } else {
        const created = await req.payload.create({
            collection: "profiles",
            data: {
                email: email,
                firstName: firstName ?? data?.name,
                lastName: lastName,
            }
        });
        profileId = created.id;
    }
 
    delete (data as Record<string, unknown>).firstName;
    delete (data as Record<string, unknown>).lastName;
    const next = { ...data, profile: profileId };
    return next;
}
 
const autoUpdateProfile: CollectionAfterChangeHook = async ({
    operation, doc, previousDoc, req
}) => {
    if (operation !== "update") return;
    if (!doc.profile) return;
    if (doc.email === previousDoc.email) return;
 
    const profileId = typeof doc.profile === "object"
        ? doc.profile.id
        : doc.profile;
 
    await req.payload.update({
        collection: "profiles",
        id: profileId,
        data: {
            email: doc.email,
        }
    })
}
 
const Users: CollectionConfig = {
    slug: "users",
    auth: {
        depth: 1,
        tokenExpiration: 604800,
        maxLoginAttempts: 5,
        lockTime: 600,
        verify: {
            generateEmailHTML: ({ token, user }) => {
                const userName = user?.name ?? user?.email ?? "friend";
                const verificationURL = getRootUrl() + `/verify?token=${token}`;
                return `Hi ${userName}, please verify your email by clicking here: <a href="${verificationURL}">${verificationURL}</a>`;
            },
            generateEmailSubject: ({ user }) => {
                const userName = user?.name ?? "friend";
                return `Hi ${userName}, please verify your email for mysite.com`;
            },
        },
        forgotPassword: {
            generateEmailHTML: (args) => {
                const token = args?.token ?? "";
                const userName = args?.user?.name ?? args?.user?.email ?? "friend";
                const resetPasswordURL = getRootUrl() + `/reset-password?token=${token}`;
                return `
                    <div>
                        <h1>Reset your password</h1>
                        <p>Hi, ${userName}</p>
                        <p>Click the link below to reset your password.</p>
                        <p>
                            <a href="${resetPasswordURL}">Reset Password</a>
                        <p>
                    </div>
                `
            },
            generateEmailSubject: (args) => {
                const userName = args?.user?.name ?? args?.user?.email ?? "friend";
                return `Hi ${userName}, reset your password for mysite.com`
            }
        }
    },
    admin: {
        useAsTitle: "name"
    },
    access: {
        create: ({}) => true,
        read: ({ req }) => isAdminAndSelf(req),
        update: ({ req }) => isAdminAndSelf(req),
        delete: ({ req }) => isAdminUser(req),
    },
    hooks: {
        afterChange: [autoUpdateProfile],
        beforeValidate: [attachProfileOnSignup],
    },
    fields: [
        {
            name: "name",
            type: "text",
            required: false,
            hooks: {
                afterRead: [populateName],
                beforeChange: [populateName],
            }
        },
        {
            name: "role",
            type: "select",
            options: [
                "admin",
                "editor",
                "web-user"
            ],
            defaultValue: "web-user",
        },
        {
            name: "profile",
            type: "relationship",
            relationTo: "profiles",
            hasMany: false,
            admin: {
                readOnly: true,
            },
            hooks: {
                beforeChange: [autoLinkProfile],
            },
            access: {
                create: () => false,
                update: () => false,
            }
        }
    ]
}
 
export default Users;

Turn dev back on and visit Payload Admin to update



6. Update Profiles Collection

Make sure you've entered Payload Admin once to trigger Profiles collection.

First, let's add an additional access control rule:

root/payload/functions/accessControl.ts
...
export const isAdminAndSelfProfile = (req: PayloadRequest) => {
    // admins: full access
    if (req.user?.collection === "users" && req.user?.role === "admin") return true;
 
    // logged-in user: restrict to their own profile doc
    if (req.user?.collection === "users") {
        const p = req.user.profile as unknown;
 
        const profileId =
        typeof p === "object" && p !== null && "id" in p
            ? // populated (depth >= 1)
            (p as { id: string | number }).id
            : // unpopulated (just an ID)
            (p as string | number);
 
        return { id: { equals: profileId } };
    }
 
    return false;
};

Update Profiles Collection:

root/payload/collections/profiles.ts
import { CollectionConfig } from "payload";
import { isAdminAndSelfProfile, isAdminUser } from "../functions/accessControl";
 
const Profiles: CollectionConfig = {
    slug: "profiles",
    admin: {
        useAsTitle: "email"
    },
    access: {
        create: ({}) => false,
        read: ({ req }) => isAdminAndSelfProfile(req),
        update: ({ req }) => isAdminAndSelfProfile(req),
        delete: ({ req }) => isAdminUser(req),
    },
    fields: [
        {
            name: "email",
            label: "Email",
            type: "email",
            unique: true,
            required: true,
            admin: {
                readOnly: true,
            }
        },
        {
            name: "firstName",
            label: "First Name",
            type: "text",
            required: false,
        },
        {
            name: "lastName",
            label: "Last Name",
            type: "text",
            required: false,
        },
        {
            name: "profileImage",
            label: "Profile Image",
            type: "text",   // will change to upload later
            required: false,
        },
        {
            name: "gender",
            label: "Gender",
            type: "select",
            required: false,
            options: [
                {
                    label: "Male",
                    value: "Male",
                },
                {
                    label: "Female",
                    value: "Female",
                },
                {
                    label: "Unspecified",
                    value: "Unspecified",
                },
            ]
        },
        {
            name: "address1",
            label: "Address Line 1",
            type: "textarea",
            required: false,
        },
        {
            name: "address2",
            label: "Address Line 2",
            type: "textarea",
            required: false,
        },
        {
            name: "city",
            label: "City",
            type: "text",
            required: false,
        },
        {
            name: "state",
            label: "State/Province/Region",
            type: "text",
            required: false,
        },
        {
            name: "zipcode",
            label: "Zip/Postal Code",
            type: "number",
            required: false,
        },
        {
            name: "country",
            label: "Country",
            type: "text",
            required: false,
        }
    ]
}
 
export default Profiles;

Try create a new user from the Admin Panel. Once done, a corresponding document should be automatically created in the Profiles collection.


Note that, at this point, if you use a fake email to create an account, the user won't be verified since we have implemented a verification system.




7. Create Signup Page

app/(pages)/signup/page.tsx
'use client'
import { getRootUrl } from "@/lib/utility";
import { useRouter } from "next/navigation";
import { useState } from "react"
 
export default function SignupPage() {
 
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [confirmPassword, setConfirmPassword] = useState("");
    const [firstName, setFirstName] = useState("");
    const [lastName, setLastName] = useState("");
    const [status, setStatus] = useState("");
    const [loading, setLoading] = useState(false);
 
    const router = useRouter();
 
    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setStatus("");
        setLoading(true);
        
        if (password !== confirmPassword) {
            setStatus("Passwords do not match.");
            setLoading(false);
            return;
        }
 
        try {
            const rootUrl = getRootUrl();
 
            const res = await fetch(`${rootUrl}/api/users`, {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
                credentials: 'include', // Important for cookie-based auth
                body: JSON.stringify({
                    email,
                    password,
                    firstName,
                    lastName,
                }),
            });
 
            if (!res.ok) {
                throw new Error(`An unexpected error occurred. Please try again later.`);
            };
 
            if (res.ok) {
                setStatus("Account created successfully! Please check your email to verify.");
                setTimeout(() => {
                    router.push("/login");
                }, 5000)
            } 
        } catch (error) {
            console.error("Network or unexpected error during signup: ", error);
            setStatus("An unexpected error occurred. Please try again later.");
        } finally {
            setLoading(false);
        }
    };
 
    return (
        <main className="w-full space-y-4">
            <h1>Signup Page</h1>
 
            <form 
                onSubmit={handleSubmit}
                className="flex flex-col gap-4 w-full max-w-[400px] text-sm"
            >
                <div className="flex flex-col gap-2 w-full">
                    <label htmlFor="firstName">First Name:</label>
                    <input
                        type="text"
                        id="firstName"
                        value={firstName}
                        onChange={(e) => setFirstName(e.target.value)}
                        required
                        disabled={loading}
                        className="w-full py-2 px-4 bg-gray-700 text-white"
                    />
                </div>
                <div className="flex flex-col gap-2 w-full">
                    <label htmlFor="lastName">Last Name:</label>
                    <input
                        type="text"
                        id="lastName"
                        value={lastName}
                        onChange={(e) => setLastName(e.target.value)}
                        required
                        disabled={loading}
                        className="w-full py-2 px-4 bg-gray-700 text-white"
                    />
                </div>
                <div className="flex flex-col gap-2 w-full">
                    <label htmlFor="email">Email:</label>
                    <input
                        type="email"
                        id="email"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        required
                        disabled={loading}
                        className="w-full py-2 px-4 bg-gray-700 text-white"
                    />
                </div>
                <div className="flex flex-col gap-2 w-full">
                    <label htmlFor="password">Password:</label>
                    <input
                        type="password"
                        id="password"
                        value={password}
                        onChange={(e) => setPassword(e.target.value)}
                        required
                        disabled={loading}
                        className="w-full py-2 px-4 bg-gray-700 text-white"
                    />
                </div>
                <div className="flex flex-col gap-2 w-full">
                    <label htmlFor="confirmPassword">Confirm Password:</label>
                    <input
                        type="password"
                        id="confirmPassword"
                        value={confirmPassword}
                        onChange={(e) => setConfirmPassword(e.target.value)}
                        required
                        disabled={loading}
                        className="w-full py-2 px-4 bg-gray-700 text-white"
                    />
                </div>
                <div>
                    <button
                        type="submit"
                        disabled={loading}
                        className="py-2 px-4 bg-emerald-600 disabled:opacity-50 cursor-pointer"
                        >Sign Up</button>
                </div>
            </form>
 
            {status && <p className="text-sm text-sky-500">{status}</p>}
 
        </main>
    )
}

8. Create Verify Page

app/components/auth/VerifyHandler.tsx
'use client';
import { getRootUrl } from "@/lib/utility";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
 
export default function VerifyHandler() {
    const router = useRouter();
    const searchParams = useSearchParams();
    const [status, setStatus] = useState("");
    const [loading, setLoading] = useState(false);
 
    useEffect(() => {
        const verifyAccount = async () => {
            setLoading(true);
            const token = searchParams.get("token");
 
            if (!token) {
                setStatus("Error: No verification token found.");
                setLoading(false);
                return;
            }
 
            try {
                const rootUrl = getRootUrl();
                const res = await fetch(`${rootUrl}/api/users/verify/${token}`, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                    },
                });
 
                if (res.ok) {
                    setStatus("Account verified successfully! Redirecting to login...");
                    setLoading(false);
                    setTimeout(() => {
                        router.push("/login");
                    }, 5000);
                } else {
                    const error = await res.json();
                    setLoading(false);
                    console.log("Verification failed: ", error.message);
                    setStatus(`Verification failed: You may have already been verified or using the wrong url. Try login to your account first and see what's up.`);
                }
            } catch (error) {
                console.error("Verification error: ", error);
                setLoading(false);
                setStatus("An unexpected error occurred during verification.");
            }
        };
 
        verifyAccount();
    }, [searchParams, router]);
 
    return (
        <div>
            <h1>Email Verification</h1>
            <p>{status}</p>
            {loading && (
                <div className="flex justify-center items-center">
                    <div className="w-12 h-12 rounded-full animate-spin
                        border-4 border-solid border-sky-500 border-t-transparent shadow-md">
                    </div>
                </div>
            )}
        </div>
    );
}
app/(pages)/verify/page.tsx
import { Suspense } from "react";
import VerifyHandler from "../../components/auth/VerifyHandler";
 
export default function VerifyPage() {
  return (
    <Suspense fallback={<p className="text-center text-sm mt-md">Loading verify...</p>}>
      <VerifyHandler />
    </Suspense>
  );
}


9. Create Login Page

app/(pages)/login/page.tsx
'use client'
import { getRootUrl } from "@/lib/utility";
import { useRouter } from "next/navigation";
import { useState } from "react"
 
export default function LoginPage() {
 
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [status, setStatus] = useState("");
    const [loading, setLoading] = useState(false);
    const router = useRouter();
 
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setStatus("");
        setLoading(true);
 
        try {
            const rootUrl = getRootUrl();
            const res = await fetch(`${rootUrl}/api/users/login`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                // IMPORTANT: This tells the browser to include the HTTP-only cookie
                credentials: 'include',
                body: JSON.stringify({
                    email,
                    password,
                }),
            });
 
            if (res.ok) {
                // Login successful, redirect the user to homepage
                router.push('/');
            } else {
                const errorData = await res.json();
                setStatus(errorData.message || 'Login failed.');
            }
 
        } catch (error) {
            console.error('Login error:', error);
            setStatus('An unexpected error occurred. Please try again.');
        } finally {
            setLoading(false);
        }
    }
 
    return (
        <main className="flex flex-col">
            <div className="bg-gray-600 p-8 rounded-lg shadow-md w-full max-w-md">
                <h1 className="text-3xl font-bold mb-6">Login</h1>
                <form onSubmit={handleSubmit} className="space-y-4">
                    <div>
                        <label className="block text-sm font-medium">Email address</label>
                        <input
                            type="email"
                            value={email}
                            onChange={(e) => setEmail(e.target.value)}
                            required
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
                        />
                    </div>
                    <div>
                        <label className="block text-sm font-medium">Password</label>
                        <input
                            type="password"
                            value={password}
                            onChange={(e) => setPassword(e.target.value)}
                            required
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
                        />
                    </div>
                    <button
                        type="submit"
                        disabled={loading}
                        className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
                    >
                        {loading ? (
                            <div className="flex justify-center items-center">
                                <div className="w-6 h-6 rounded-full animate-spin
                                    border-4 border-solid border-white border-t-transparent shadow-md">
                                </div>
                            </div>
                        ) : <span>Login</span>}
                    </button>
                    {status && (
                        <div className="text-sm text-amber-300 place-self-center">
                            <p className="">{status} Please make sure:</p>
                            <p>Your credentials are correct.</p>
                            <p>You have verified your email.</p>
                        </div>
                    )}
                </form>
            </div>
        </main>
    );
}

At this point, you should test signing up. You should sign up successfully, get email verification, and login.



10. Show user's name on the homepage

This is for testing. We will switch to TanStack to manage user global state later.

app/page.tsx
'use client'
import { useEffect, useState } from 'react';
 
// Define a type for your user object to ensure type safety
interface User {
  id: string;
  email: string;
  firstName: string;
}
 
export default function Home() {
    const [user, setUser] = useState<User | null>(null);
    const [loading, setLoading] = useState(true);
 
    useEffect(() => {
        const fetchUser = async () => {
            try {
                const res = await fetch('/api/users/me', {
                    method: 'GET',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    // This is crucial! It ensures the browser sends the session cookie.
                    credentials: 'include',
                });
 
                if (res.ok) {
                    const data = await res.json();
                    if (data.user) {
                        setUser(data.user);
                    } else {
                        // User is not authenticated, API returns no user
                        setUser(null);
                    }
                } else {
                    // Handle non-ok responses (e.g., 401 Unauthorized)
                    setUser(null);
                }
            } catch (error) {
                console.error('Failed to fetch user:', error);
                setUser(null);
            } finally {
                setLoading(false);
            }
        };
 
        fetchUser();
    }, []);
 
    // Show a loading state while fetching the user
    if (loading) {
        return (
            <div>
                <h1>Test Payload Auth</h1>
                <p>Loading...</p>
            </div>
        );
    }
 
    return (
        <div>
            <h1>Test Payload Auth</h1>
            {user ? (
                // Display the user's first name if available, otherwise use their email
                <p>Welcome, {user.firstName || user.email}</p>
            ) : (
                <p>Welcome, guest</p>
            )}
        </div>
    );
}


11. Create Logout Page

This is temporary. We will won't need it anymore when we implement global state with TanStack.

app/(pages)/logout/page.tsx
'use client'
import { getRootUrl } from "@/lib/utility";
import { useRouter } from "next/navigation";
 
export default function LogoutPage() {
    const router = useRouter();
 
    const handleLogout = async () => {
        try {
            const rootUrl = getRootUrl();
 
            const res = await fetch(`${rootUrl}/api/users/logout`, {
                method: "POST",
                headers: {
                    "Content-Type": "application/json"
                },
                credentials: "include",
            });
 
            if (!res.ok) {
                throw new Error(`Server responded with status: ${res.status} ${res.statusText}`)
            }
 
            router.push("/");
            
        } catch (error) {
            console.error("An error occurred. ", (error as Error).message);
        }
    }
 
    return (
        <main className="flex flex-col">
            <div>
                <button
                    onClick={handleLogout}
                    className="bg-amber-600 py-2 px-4 text-sm cursor-pointer"
                    >Logout</button>
            </div>
        </main>
    );
}


12. Create Forgot Password Page

app/(pages)/forgot-password/page.tsx
'use client'
import { getRootUrl } from '@/lib/utility';
import { useState } from 'react';
 
export default function ForgotPasswordPage() {
    const [email, setEmail] = useState('');
    const [status, setStatus] = useState('');
    const [loading, setLoading] = useState(false);
 
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setStatus('');
        setLoading(true);
 
        try {
            const rootUrl = getRootUrl();
            const res = await fetch(`${rootUrl}/api/users/forgot-password`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ email }),
            });
 
            if (res.ok) {
                setStatus('If an account with that email exists, a password reset link has been sent.');
                setEmail('');
            } else {
                // For security, it's a best practice to give a generic message even on an error
                setStatus('If an account with that email exists, a password reset link has been sent.');
            }
        } catch (err) {
            console.error('Forgot password error:', err);
            setStatus('An unexpected error occurred. Please try again.');
        } finally {
            setLoading(false);
        }
    };
 
    return (
        <main className="flex flex-col">
            <div className="bg-gray-600 p-8 rounded-lg shadow-md w-full max-w-md">
                <h1 className="text-3xl font-bold mb-6 text-center">Forgot Password</h1>
                <p className="text-center mb-4">
                    Enter your email to receive a password reset link.
                </p>
                <form onSubmit={handleSubmit} className="space-y-4">
                    <div>
                        <label className="block text-sm font-medium">Email address</label>
                        <input
                            type="email"
                            value={email}
                            onChange={(e) => setEmail(e.target.value)}
                            required
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
                        />
                    </div>
                    {status && (
                        <p className={`text-sm text-center ${status.includes('error') ? 'text-red-500' : 'text-green-500'}`}>
                            {status}
                        </p>
                    )}
                    <button
                        type="submit"
                        disabled={loading}
                        className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50"
                    >
                        {loading ? 
                            <div className="flex justify-center items-center">
                                <div className="w-6 h-6 rounded-full animate-spin
                                    border-4 border-solid border-white border-t-transparent shadow-md">
                                </div>
                            </div>
                        : 'Send Reset Link'}
                    </button>
                </form>
            </div>
        </main>
    );
}


13. Create Reset Password Page

app/components/auth/ResetPasswordHandler.tsx
'use client'
import { getRootUrl } from '@/lib/utility';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
 
const ResetPasswordHandler = () => {
    const router = useRouter();
    const searchParams = useSearchParams();
    const [password, setPassword] = useState('');
    const [passwordConfirm, setPasswordConfirm] = useState('');
    const [error, setError] = useState('');
    const [status, setStatus] = useState('');
    const [loading, setLoading] = useState(false);
    const [token, setToken] = useState<string | null>(null);
 
    useEffect(() => {
        const urlToken = searchParams.get('token');
        if (urlToken) {
            setToken(urlToken);
        } else {
            setError('Invalid or missing password reset token.');
        }
    }, [searchParams]);
 
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setError('');
        setStatus('');
        setLoading(true);
 
        if (password !== passwordConfirm) {
            setError('Passwords do not match.');
            setLoading(false);
            return;
        }
 
        if (!token) {
            setError('Invalid or missing password reset token.');
            setLoading(false);
            return;
        }
 
        try {
            const rootUrl = getRootUrl();
            const res = await fetch(`${rootUrl}/api/users/reset-password`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    token,
                    password,
                }),
            });
 
            if (res.ok) {
                setStatus('Password reset successfully! Redirecting to login...');
                setTimeout(() => {
                    router.push('/login');
                }, 3000);
            } else {
                const errorData = await res.json();
                setError(errorData.message || 'Password reset failed. The token may be expired or invalid.');
            }
        } catch (err) {
            console.error('Reset password error:', err);
            setError('An unexpected error occurred. Please try again.');
        } finally {
            setLoading(false);
        }
    };
 
    if (!token) {
        return (
            <main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
                <p className="text-red-500">{error}</p>
            </main>
        );
    }
 
    return (
        <main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
            <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
                <h1 className="text-3xl font-bold mb-6 text-center text-gray-800">Reset Password</h1>
                <p className="text-center mb-4 text-gray-600">
                    Please enter your new password.
                </p>
                <form onSubmit={handleSubmit} className="space-y-4">
                    <div>
                        <label className="block text-sm font-medium text-gray-700">New Password</label>
                        <input
                            type="password"
                            value={password}
                            onChange={(e) => setPassword(e.target.value)}
                            required
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
                        />
                    </div>
                    <div>
                        <label className="block text-sm font-medium text-gray-700">Confirm New Password</label>
                        <input
                            type="password"
                            value={passwordConfirm}
                            onChange={(e) => setPasswordConfirm(e.target.value)}
                            required
                            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
                        />
                    </div>
                    {status && (
                        <p className="text-sm text-center text-green-500">{status}</p>
                    )}
                    {error && (
                        <p className="text-sm text-center text-red-500">{error}</p>
                    )}
                    <button
                        type="submit"
                        disabled={loading}
                        className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50"
                    >
                        {loading ?
                            <div className="flex justify-center items-center">
                                <div className="w-6 h-6 rounded-full animate-spin
                                    border-4 border-solid border-white border-t-transparent shadow-md">
                                </div>
                            </div>
                        : 'Reset Password'}
                    </button>
                </form>
            </div>
        </main>
    );
}
 
export default ResetPasswordHandler;
app/(pages)/reset-password/page.tsx
import { Suspense } from "react";
import ResetPasswordHandler from "../../components/auth/ResetPasswordHandler";
 
export default function VerifyPage() {
  return (
    <Suspense fallback={<p className="text-center text-sm mt-md">Loading verify...</p>}>
      <ResetPasswordHandler />
    </Suspense>
  );
}


14. Admin Panel Access Control

For references, visit: Payload Access Control: Admin and Restrict Access Control

Add the following rule to our existing accessControl.ts

payload/functions/accessControl.ts
export const adminAccessControl = ({ req }: { req: PayloadRequest }): boolean | Promise<boolean> => {
  const user = req.user
 
  if (user && (user.role?.includes('admin') || user.role?.includes('editor'))) {
    return true // Allow access to admin and editor
  }
 
  return false // Deny access for all other roles
}

We have to apply this rule the auth collection under the access -> admin property.

root/payload/collections/users.ts
...
    access: {
        create: ({}) => true,
        read: ({ req }) => isAdminAndSelf(req),
        update: ({ req }) => isAdminAndSelf(req),
        delete: ({ req }) => isAdminUser(req),
        admin: adminAccessControl,
    },
...

We are done with Account Functionality. Next, it's time to setup global states with TanStack React Query Global User State

JKT

Stay focused, and the rest will follow

©Jakkrit Turner. All rights reserved