User Profiles
Implementing user profile caching with self-view and public-view patterns
User Profiles Scenario
Learn how to implement caching for a social platform where users see their own changes immediately, while others see eventually consistent data.
The Challenge
User profile systems have unique caching requirements:
- Self-view: Users editing their own profile expect instant updates
- Public view: Other users viewing a profile can tolerate slight delays
- Admin view: Moderators need to see current state for moderation
- Privacy: Some data should only be cached for the user themselves
Schema Design
// lib/cache.ts
import { createCache } from 'next-cool-cache';
const schema = {
users: {
list: {},
byId: { _params: ['id'] as const },
byUsername: { _params: ['username'] as const },
settings: {
byUserId: { _params: ['userId'] as const },
},
followers: {
byUserId: { _params: ['userId'] as const },
count: { _params: ['userId'] as const },
},
following: {
byUserId: { _params: ['userId'] as const },
count: { _params: ['userId'] as const },
},
activity: {
byUserId: { _params: ['userId'] as const },
},
},
feed: {
byUserId: { _params: ['userId'] as const },
global: {},
},
notifications: {
byUserId: { _params: ['userId'] as const },
unreadCount: { _params: ['userId'] as const },
},
} as const;
const scopes = ['admin', 'public', 'self'] as const;
export const cache = createCache(schema, scopes);Implementation Patterns
Pattern 1: Public Profile Page
When viewing another user's profile:
// app/users/[username]/page.tsx
import { cache } from '@/lib/cache';
async function getPublicProfile(username: string) {
'use cache: remote';
cache.public.users.byUsername.cacheTag({ username });
return db.users.findUnique({
where: { username },
select: {
id: true,
username: true,
displayName: true,
bio: true,
avatarUrl: true,
createdAt: true,
// Note: email, settings excluded for privacy
},
});
}
async function getFollowerCount(userId: string) {
'use cache: remote';
cache.public.users.followers.count.cacheTag({ userId });
return db.followers.count({ where: { followingId: userId } });
}
async function getFollowingCount(userId: string) {
'use cache: remote';
cache.public.users.following.count.cacheTag({ userId });
return db.followers.count({ where: { followerId: userId } });
}
export default async function ProfilePage({ params }: { params: { username: string } }) {
const user = await getPublicProfile(params.username);
if (!user) notFound();
const [followers, following] = await Promise.all([
getFollowerCount(user.id),
getFollowingCount(user.id),
]);
return (
<ProfileLayout>
<ProfileHeader user={user} followers={followers} following={following} />
<ProfileContent userId={user.id} />
</ProfileLayout>
);
}Pattern 2: Self Profile View
When viewing your own profile, use the self scope:
// app/profile/page.tsx
import { cache } from '@/lib/cache';
import { getCurrentUser } from '@/lib/auth';
async function getSelfProfile(userId: string) {
'use cache: private';
cache.self.users.byId.cacheTag({ id: userId });
return db.users.findUnique({
where: { id: userId },
// Include private fields for self-view
select: {
id: true,
username: true,
displayName: true,
bio: true,
avatarUrl: true,
email: true,
emailVerified: true,
createdAt: true,
},
});
}
async function getSelfSettings(userId: string) {
'use cache: private';
cache.self.users.settings.byUserId.cacheTag({ userId });
return db.userSettings.findUnique({
where: { userId },
});
}
export default async function MyProfilePage() {
const currentUser = await getCurrentUser();
if (!currentUser) redirect('/login');
const [profile, settings] = await Promise.all([
getSelfProfile(currentUser.id),
getSelfSettings(currentUser.id),
]);
return (
<ProfileLayout>
<EditableProfileHeader profile={profile} />
<ProfileSettings settings={settings} />
</ProfileLayout>
);
}Pattern 3: Profile Update
When a user updates their profile:
// app/actions/profile.ts
'use server';
import { cache } from '@/lib/cache';
import { getCurrentUser } from '@/lib/auth';
export async function updateProfile(data: ProfileUpdateData) {
const currentUser = await getCurrentUser();
if (!currentUser) throw new Error('Unauthorized');
const user = await db.users.update({
where: { id: currentUser.id },
data,
});
// Self sees immediately
cache.self.users.byId.updateTag({ id: user.id });
// Public sees via SWR
cache.public.users.byId.revalidateTag({ id: user.id });
cache.public.users.byUsername.revalidateTag({ username: user.username });
// If username changed, also invalidate the old username
if (data.username && data.username !== currentUser.username) {
cache.public.users.byUsername.revalidateTag({ username: currentUser.username });
}
return user;
}
export async function updateSettings(data: SettingsUpdateData) {
const currentUser = await getCurrentUser();
if (!currentUser) throw new Error('Unauthorized');
const settings = await db.userSettings.update({
where: { userId: currentUser.id },
data,
});
// Settings are self-only, no public cache
cache.self.users.settings.byUserId.updateTag({ userId: currentUser.id });
return settings;
}Pattern 4: Follow/Unfollow
// app/actions/follow.ts
'use server';
import { cache } from '@/lib/cache';
import { getCurrentUser } from '@/lib/auth';
export async function followUser(targetUserId: string) {
const currentUser = await getCurrentUser();
if (!currentUser) throw new Error('Unauthorized');
await db.followers.create({
data: {
followerId: currentUser.id,
followingId: targetUserId,
},
});
// Update follower counts
cache.public.users.followers.count.revalidateTag({ userId: targetUserId });
cache.public.users.following.count.revalidateTag({ userId: currentUser.id });
// Update follower lists
cache.public.users.followers.byUserId.revalidateTag({ userId: targetUserId });
cache.self.users.following.byUserId.updateTag({ userId: currentUser.id });
// Update feeds
cache.self.feed.byUserId.revalidateTag({ userId: currentUser.id });
// Notify the target user (clear their notification cache)
cache.self.notifications.byUserId.revalidateTag({ userId: targetUserId });
cache.self.notifications.unreadCount.revalidateTag({ userId: targetUserId });
}
export async function unfollowUser(targetUserId: string) {
const currentUser = await getCurrentUser();
if (!currentUser) throw new Error('Unauthorized');
await db.followers.delete({
where: {
followerId_followingId: {
followerId: currentUser.id,
followingId: targetUserId,
},
},
});
// Update counts
cache.public.users.followers.count.revalidateTag({ userId: targetUserId });
cache.public.users.following.count.revalidateTag({ userId: currentUser.id });
// Update lists
cache.public.users.followers.byUserId.revalidateTag({ userId: targetUserId });
cache.self.users.following.byUserId.updateTag({ userId: currentUser.id });
// Update feed
cache.self.feed.byUserId.revalidateTag({ userId: currentUser.id });
}Pattern 5: Feed Updates
// app/feed/page.tsx
import { cache } from '@/lib/cache';
import { getCurrentUser } from '@/lib/auth';
async function getPersonalizedFeed(userId: string) {
'use cache: private';
cache.self.feed.byUserId.cacheTag({ userId });
// Get posts from users the current user follows
return db.posts.findMany({
where: {
author: {
followers: {
some: { followerId: userId },
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
include: { author: true },
});
}
async function getGlobalFeed() {
'use cache: remote';
cache.public.feed.global.cacheTag();
return db.posts.findMany({
where: { visibility: 'public' },
orderBy: { createdAt: 'desc' },
take: 50,
include: { author: true },
});
}Pattern 6: Admin Moderation
// app/admin/users/[id]/page.tsx
import { cache } from '@/lib/cache';
async function getAdminUserView(userId: string) {
'use cache: remote';
cache.admin.users.byId.cacheTag({ id: userId });
return db.users.findUnique({
where: { id: userId },
include: {
settings: true,
reports: true,
suspensions: true,
},
});
}
// app/actions/moderation.ts
'use server';
export async function suspendUser(userId: string, reason: string) {
await db.users.update({
where: { id: userId },
data: { suspended: true, suspensionReason: reason },
});
// Admin sees immediately
cache.admin.users.byId.updateTag({ id: userId });
// Invalidate all this user's public data
cache.users.byId.revalidateTag({ id: userId });
// Clear their session-related caches
cache.self.users.byId.revalidateTag({ id: userId });
cache.self.feed.byUserId.revalidateTag({ userId });
}Privacy Considerations
Separating Public and Private Data
Use different cache entries for public vs private data:
// Public profile - anyone can see
async function getPublicProfile(userId: string) {
'use cache: remote';
cache.public.users.byId.cacheTag({ id: userId });
return db.users.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
displayName: true,
bio: true,
avatarUrl: true,
},
});
}
// Private profile - only the user themselves
async function getPrivateProfile(userId: string) {
'use cache: private';
cache.self.users.byId.cacheTag({ id: userId });
return db.users.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
phone: true,
settings: true,
},
});
}User Data Deletion (GDPR)
When a user deletes their account:
export async function deleteAccount(userId: string) {
// Delete user data from database
await db.users.delete({ where: { id: userId } });
// Invalidate ALL caches for this user across all scopes
cache.users.byId.revalidateTag({ id: userId });
// Invalidate related data
cache.feed.byUserId.revalidateTag({ userId });
cache.notifications.byUserId.revalidateTag({ userId });
// Invalidate global feeds that might contain their content
cache.public.feed.global.revalidateTag();
}Frequently Asked Questions
How do I edit my profile and immediately see the changes?
Use updateTag() on the self scope for instant feedback on your own profile.
async function updateProfile(userId: string, data: ProfileData) {
await db.users.update({ where: { id: userId }, data });
// Self sees immediately (your own profile should update instantly)
cache.self.users.byId.updateTag({ id: userId });
// → updateTag('self/users/byId:user-123')
}UX: When you edit your display name, bio, or avatar, you see the changes immediately on your own profile page. No stale data, no waiting - instant feedback that your changes were saved.
How do I edit my profile and only revalidate for others (SWR)?
Use revalidateTag() on the public scope - others can tolerate brief staleness.
async function updateProfile(userId: string, data: ProfileData) {
const user = await db.users.update({ where: { id: userId }, data });
// Self sees immediately
cache.self.users.byId.updateTag({ id: userId });
// → updateTag('self/users/byId:user-123')
// Public sees via SWR (they can tolerate brief staleness)
cache.public.users.byId.revalidateTag({ id: userId });
// → revalidateTag('public/users/byId:user-123', 'max')
cache.public.users.byUsername.revalidateTag({ username: user.username });
// → revalidateTag('public/users/byUsername:johndoe', 'max')
}UX: You see your changes instantly. Others viewing your profile see the old version briefly, then it auto-updates in the background. They never see a loading spinner - just a smooth transition to your updated profile on their next visit.
How do I handle username changes across all cached pages?
Invalidate BOTH the old and new username cache entries.
async function changeUsername(userId: string, oldUsername: string, newUsername: string) {
await db.users.update({ where: { id: userId }, data: { username: newUsername } });
// Invalidate the old username (should return 404 now)
cache.public.users.byUsername.updateTag({ username: oldUsername });
// → updateTag('public/users/byUsername:old-name')
// Invalidate the new username (should resolve to this user now)
cache.public.users.byUsername.updateTag({ username: newUsername });
// → updateTag('public/users/byUsername:new-name')
// Also update the byId cache
cache.self.users.byId.updateTag({ id: userId });
// → updateTag('self/users/byId:user-123')
cache.public.users.byId.revalidateTag({ id: userId });
// → revalidateTag('public/users/byId:user-123', 'max')
}UX: Links to the old username (/users/old-name) immediately return 404 or redirect. Links to the new username (/users/new-name) immediately show the correct profile. No confusion about which username is valid.
Should follower counts update immediately or use SWR?
Use revalidateTag() for follower counts - exact numbers aren't critical.
async function followUser(currentUserId: string, targetUserId: string) {
await db.followers.create({
data: { followerId: currentUserId, followingId: targetUserId }
});
// Follower counts - SWR is fine (not critical if off by 1 briefly)
cache.public.users.followers.count.revalidateTag({ userId: targetUserId });
// → revalidateTag('public/users/followers/count:user-456', 'max')
cache.public.users.following.count.revalidateTag({ userId: currentUserId });
// → revalidateTag('public/users/following/count:user-123', 'max')
// The follower list for the target user
cache.public.users.followers.byUserId.revalidateTag({ userId: targetUserId });
// → revalidateTag('public/users/followers/byUserId:user-456', 'max')
// Your own following list should update immediately
cache.self.users.following.byUserId.updateTag({ userId: currentUserId });
// → updateTag('self/users/following/byUserId:user-123')
}UX: Follower counts like "1,234 followers" might lag by a few seconds - nobody notices if it says 1,234 vs 1,235. But YOUR following list updates immediately so you see that you're now following someone. The target user's follower count updates in the background.
How do I ensure deleted accounts are removed from all caches?
Use scope-less invalidation to clear across ALL scopes at once.
async function deleteAccount(userId: string) {
const user = await db.users.findUnique({ where: { id: userId } });
await db.users.delete({ where: { id: userId } });
// Invalidate across ALL scopes (self, public, admin) using scope-less access
cache.users.byId.revalidateTag({ id: userId });
// → revalidateTag('users/byId:user-123', 'max')
// This invalidates self/users/byId:user-123, public/users/byId:user-123, AND admin/users/byId:user-123
cache.users.byUsername.revalidateTag({ username: user.username });
// → revalidateTag('users/byUsername:johndoe', 'max')
// Also invalidate related data
cache.feed.byUserId.revalidateTag({ userId });
// → revalidateTag('feed/byUserId:user-123', 'max')
cache.notifications.byUserId.revalidateTag({ userId });
// → revalidateTag('notifications/byUserId:user-123', 'max')
// Global feed might have their content
cache.public.feed.global.revalidateTag();
// → revalidateTag('public/feed/global', 'max')
}UX: The deleted user's profile returns 404 everywhere immediately. Their content disappears from feeds. No cached references to the deleted account remain, satisfying GDPR requirements for data deletion.
How do I separate private settings from public profile data?
Use different cache entries with different scopes - self for private, public for public.
// Public profile - visible to everyone
async function getPublicProfile(userId: string) {
'use cache: remote';
cache.public.users.byId.cacheTag({ id: userId });
// → cacheTag('public/users/byId:user-123', ...)
return db.users.findUnique({
where: { id: userId },
select: { id: true, username: true, displayName: true, bio: true, avatarUrl: true }
});
}
// Private settings - only visible to the user themselves
async function getPrivateSettings(userId: string) {
'use cache: private';
cache.self.users.settings.byUserId.cacheTag({ userId });
// → cacheTag('self/users/settings/byUserId:user-123', ...)
return db.userSettings.findUnique({
where: { userId },
select: { email: true, phone: true, notificationPrefs: true, twoFactorEnabled: true }
});
}
// When updating settings, only invalidate the self scope
async function updateSettings(userId: string, settings: SettingsData) {
await db.userSettings.update({ where: { userId }, data: settings });
cache.self.users.settings.byUserId.updateTag({ userId });
// → updateTag('self/users/settings/byUserId:user-123')
// Public profile is NOT affected
}UX: Private data (email, phone, 2FA settings) is cached separately under the self scope and never exposed to other users. Public profile data is cached under the public scope. The two never mix - updating settings doesn't affect public profile cache, and vice versa.
Key Takeaways
- Self scope for user's own data: Users expect instant updates to their own profile
- Public scope for others: Viewers of a profile can tolerate SWR
- Separate public/private data: Cache them differently for security
- Update related caches on actions: Follow/unfollow affects multiple caches
- Cross-scope for deletions: User deletion should invalidate everywhere