Global User State

Used in this guide:
Next.js 15.3.4
Payload CMS

Requirement:

  • Payload Setup: link
  • Account Functionality: link



1. Install TanStack Reach Query


npm install @tanstack/react-query


2. Create auth functions

SSR Version

This version will be used in main layout.tsx to prefetch and cache user data since we don't want to make the whole site CSR.

lib/payload/auth.ts
import { headers } from "next/headers";
import { getRootUrl } from "../utility";
import { User } from "@/payload-types";
 
export async function getUser(): Promise<User | null> {
    try {
        const cookie = (await headers()).get('cookie');
        if (!cookie) {
            return null;
        }
 
        const rootUrl = getRootUrl();
 
        const res = await fetch(`${rootUrl}/api/users/me`, {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
                "Cookie": cookie,
            },
        });
 
        if (!res.ok) {
            throw new Error(`Server responses with ${res.status}`);
        }
        
        const data = await res.json();
        if (data.user) {
            return data.user;
        } else {
            throw new Error("Can't find user object or invalid data format");
        }
    } catch (error) {
        console.error("Error getting the user: ", (error as Error).message);
        return null
    }
}

CSR Version

We will use this version when we create useUser hook with TanStack useQuery. This version uses credentials instead of cookie and it works with CSR components.

lib/payload/authClient.ts
import { getRootUrl } from "../utility";
 
export async function getUserClient() {
    try {
        const rootUrl = getRootUrl();
 
        const res = await fetch(`${rootUrl}/api/users/me`, {
            method: "GET",
            headers: {
                "Content-Type": "application/json",
            },
            credentials: "include",
        });
        if (!res.ok) {
            return null;
        }
 
        const data = await res.json();
        return data.user || null;
    } catch (error) {
        console.error("Error getting the user on client: ", (error as Error).message);
        return null;
    }
}


3. Create useUser Hook

This hook is used to run the getUserClient function we created above. The benefits of using TanStack for this is that you can track states and cache data.

hooks/useUser.ts
'use client';
import { getUserClient } from "@/lib/payload/authClient";
import { useQuery } from "@tanstack/react-query";
 
export const useUser = () => {
 
    // call(run) getUser function
    // track the following states: loading, error, and data
    // cache the result with the key 'user'
    // benefits: reuse cached data instantly, avoid duplicate requests, automatically refetch in the background (optional)
 
    const { data, isLoading, isError, error } = useQuery({
        queryKey: ['user'],
        queryFn: getUserClient,
    });
 
    return { data, isLoading, isError, error };
}


4. Create QueryProvider


app/components/QueryProvider.tsx
'use client'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactNode, useState } from "react"
 
export default function QueryProvider({ children }: { children: ReactNode }) {
 
    const [queryClient] = useState(() => new QueryClient());
 
    return (
        <QueryClientProvider client={queryClient}>
            {children}
        </QueryClientProvider>
    )
}


5. Put QueryProvider in the Main Layout

app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "./components/Navbar";
import QueryProvider from "./components/QueryProvider";
 
const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});
 
const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});
 
export const metadata: Metadata = {
  title: "N | Payload Auth",
  description: "Generated by create next app",
};
 
export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
 
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased
        w-full max-w-[1280px] p-8 mx-auto`}
      >
        <QueryProvider>
          <Navbar />
          {children}
        </QueryProvider>
      </body>
    </html>
  );
}


6. Create UserCard component

With our current setup, we are ready to call useUser hook anywhere in any client components of our app. So let's create a user card to display user info.

This example includes logout logic that point directly to built-in Payload api that we don't have to create.

app/components/UserCard.tsx
'use client'
import { useUser } from "@/hooks/useUser"
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
 
const UserCard = () => {
 
    const { data: user, isLoading } = useUser();
    const router = useRouter();
    const queryClient = useQueryClient();
 
    const handleLogout = async () => {
        try {
 
            const res = await fetch(`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}`)
            }
 
            queryClient.removeQueries({ queryKey: ['user'] });
            router.push("/login");
            
        } catch (error) {
            console.error("An error occurred. ", (error as Error).message);
        }
    }
 
    return (
        <div className="rounded-lg bg-gray-700 p-10 w-[300px]">
            <p>Name: {isLoading ? "Loading..." : (user?.profile.firstName ?? user?.name) || "Guest"}</p>
            {user && (
                <button
                    onClick={handleLogout}
                    className="py-2 px-4 text-sm bg-amber-600 mt-2 cursor-pointer"
                    >Logout</button>
            )}
        </div>
    )
}
 
export default UserCard;


7. Update Homepage

We will use our UserCard here instead.

app/page.tsx
import UserCard from "./components/UserCard";
 
export default function Home() {
    return (
        <div>
            <h1>Test Payload Auth</h1>
            <UserCard />
        </div>
    );
}

At this point, you should start the server and test how things look. If you're logged-in, you should see your name displayed on the card.




8. Create Updatable Profile Page

app/(pages)/profile/page.tsx
'use client'
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { useUser } from '@/hooks/useUser';
import { getRootUrl } from '@/lib/utility';
 
export default function ShippingAddressPage() {
    const router = useRouter();
    const { data: user, isLoading, error } = useUser();
    const queryClient = useQueryClient();
 
    const [formData, setFormData] = useState({
        firstName: '',
        lastName: '',
        address1: '',
        address2: '',
        city: '',
        state: '',
        zipcode: '',
        country: '',
    });
    const [status, setStatus] = useState('');
    const [isSubmitting, setIsSubmitting] = useState(false);
 
    // Populate the form with user data once it's loaded
    useEffect(() => {
        if (user && user.profile) { // Add a check for user.profile
            setFormData({
                firstName: user.profile.firstName ?? user.name ?? '',
                lastName: user.profile.lastName || '',
                address1: user.profile.address1 || '',
                address2: user.profile.address2 || '',
                city: user.profile.city || '',
                state: user.profile.state || '',
                zipcode: user.profile.zipcode || '',
                country: user.profile.country || '',
            });
        }
    }, [user]);
 
    // Handle form input changes
    const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
        const { name, value } = e.target;
        setFormData(prevData => ({
            ...prevData,
            [name]: value,
        }));
    };
 
    // Handle form submission
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setIsSubmitting(true);
        setStatus('');
 
        if (!user || !user.profile) { // Add a check for user.profile
            setStatus('You must be logged in to update your address.');
            setIsSubmitting(false);
            return;
        }
        
        try {
            const rootUrl = getRootUrl();
            const profileId = user.profile.id; // Get the profileId here, after the user check
 
            const res = await fetch(`${rootUrl}/api/profiles/${profileId}`, {
                method: 'PATCH',
                headers: {
                    'Content-Type': 'application/json',
                },
                credentials: 'include',
                body: JSON.stringify(formData),
            });
 
            if (res.ok) {
                await queryClient.invalidateQueries({ queryKey: ['user'] });
                setStatus('Profile updated successfully!');
            } else {
                const errorData = await res.json();
                setStatus(errorData.message || 'Failed to update profile.');
            }
        } catch (err) {
            console.error('Update address error:', err);
            setStatus('An unexpected error occurred. Please try again.');
        } finally {
            setIsSubmitting(false);
        }
    };
 
    if (isLoading) {
        return (
            <main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
                <p>Loading user data...</p>
            </main>
        );
    }
    
    if (error) {
        return (
            <main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
                <p className="text-red-500">Error: Failed to load user data.</p>
            </main>
        );
    }
 
    if (!user) {
        router.push('/login');
        return null;
    }
 
    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">Update profile</h1>
                <form onSubmit={handleSubmit} className="space-y-4">
                    {/* Input fields for all address components */}
                    <input
                        type="text"
                        name="firstName"
                        placeholder="First Name"
                        value={formData.firstName}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="lastName"
                        placeholder="Last Name"
                        value={formData.lastName}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="address1"
                        placeholder="Address Line 1"
                        value={formData.address1}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="address2"
                        placeholder="Address Line 2"
                        value={formData.address2}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="city"
                        placeholder="City"
                        value={formData.city}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="state"
                        placeholder="State/Province/Region"
                        value={formData.state}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="zipcode"
                        placeholder="Zip/Postal Code"
                        value={formData.zipcode}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    <input
                        type="text"
                        name="country"
                        placeholder="Country"
                        value={formData.country}
                        onChange={handleChange}
                        className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
                    />
                    {status && (
                        <p className={`text-sm text-center ${status.includes('success') ? 'text-green-500' : 'text-red-500'}`}>
                            {status}
                        </p>
                    )}
                    <button
                        type="submit"
                        disabled={isSubmitting}
                        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"
                    >
                        {isSubmitting ? "Loading..." : 'Update Address'}
                    </button>
                </form>
            </div>
        </main>
    );
}

At this point, you should be able to view and update your profile info freely in the profile page.




Next, you should go back to Droplet and Setup pgBouncer

And if it's not your first time, go straight to Give pgBouncer a list of users

JKT

Stay focused, and the rest will follow

©Jakkrit Turner. All rights reserved