44 KiB
Frontend Overhaul Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eliminate the author-profile fetch waterfall, replace router.refresh() with targeted Server Action cache invalidation, and fix composition throughout the frontend.
Architecture: Backend already embeds author: UserResponse in ThoughtResponse — no backend changes needed. Frontend pages ignore this and re-fetch profiles separately; we delete those calls. Mutations become Server Actions that call revalidateTag so only affected data refetches. useOptimistic (React 19) gives instant UI feedback. Large components split into focused sub-components. Near-duplicate form components unified.
Tech Stack: Next.js 15 App Router, React 19, TypeScript, Tailwind CSS v4, shadcn/ui, react-hook-form + Zod, next/cache (revalidateTag), next/headers (cookies)
Working directory: thoughts-frontend/
Key facts before you start
- Auth token lives in a cookie named
auth_token. In server components:(await cookies()).get('auth_token')?.value. In Server Actions: same. apiFetch(endpoint, options, schema, token?)is inlib/api.ts.optionsisRequestInit.ThoughtResponse(Zod:ThoughtSchema) already hasauthor: UserSchemaembedded —thought.author.username,thought.author.avatarUrl,thought.author.displayNameall exist on every thought.ThoughtThreadcomponent (not the type) re-fetches the avatar viaauthorDetails.get(username)as an override — this is now unnecessary.- Run the dev server with
npm run dev(orbun dev) fromthoughts-frontend/. - Type-check with
npx tsc --noEmit— run this after each task.
File Map
| File | Status | Change |
|---|---|---|
lib/api.ts |
modify | Add next?: NextFetchRequestConfig to apiFetch; tag GET calls |
app/actions/thoughts.ts |
create | Server Actions: createThought, deleteThought |
app/actions/social.ts |
create | Server Actions: followUser, unfollowUser |
app/actions/profile.ts |
create | Server Action: updateProfile |
app/page.tsx |
modify | Remove getUserProfile waterfall; remove authorDetails |
app/users/[username]/page.tsx |
modify | Remove getUserProfile waterfall; remove authorDetails |
app/thoughts/[thoughtId]/page.tsx |
modify | Remove getUserProfile waterfall; remove authorDetails |
app/tags/[tagName]/page.tsx |
modify | Remove getUserProfile waterfall; remove authorDetails |
app/search/page.tsx |
modify | Remove getUserProfile waterfall; remove authorDetails |
components/thought-thread.tsx |
modify | Remove authorDetails prop; use thought.author directly |
components/thought-card.tsx |
modify | Remove author prop; use thought.author directly; call Server Action for delete |
components/follow-button.tsx |
modify | Replace router.refresh() with Server Action + useOptimistic |
components/post-thought-form.tsx |
delete | Replaced by ThoughtForm |
components/reply-form.tsx |
delete | Replaced by ThoughtForm |
components/thought-form.tsx |
create | Unified form accepting a Server Action |
components/empty-state.tsx |
create | Shared empty state primitive |
components/loading-skeleton.tsx |
create | Shared skeleton primitive |
components/edit-profile-form.tsx |
modify | Replace router.refresh() with Server Action |
components/remote-user-profile.tsx |
modify | Split into sub-components |
components/remote-user-profile/profile-card.tsx |
create | Avatar, bio, stats |
components/remote-user-profile/connections.tsx |
create | Followers/following lists |
Task 1: Add next tag support to apiFetch and tag all GET fetches
Files:
-
Modify:
lib/api.ts -
Step 1: Update
apiFetchsignature to accept Next.js cache options
In lib/api.ts, change lines 154–188 (apiFetch function). Replace the function with:
type ApiFetchOptions = Omit<RequestInit, 'next'> & {
next?: { tags?: string[]; revalidate?: number | false }
}
async function apiFetch<T>(
endpoint: string,
options: ApiFetchOptions = {},
schema: z.ZodType<T>,
token?: string | null
): Promise<T> {
if (!API_BASE_URL) {
throw new Error("API_BASE_URL is not defined");
}
const { next, ...restOptions } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(restOptions.headers as Record<string, string>),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...restOptions,
headers,
...(next ? { next } : {}),
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
if (response.status === 204) {
return null as T;
}
const data = await response.json();
return schema.parse(data);
}
- Step 2: Tag the GET fetches that pages depend on
Update these functions in lib/api.ts to pass next.tags:
export const getMe = (token: string) =>
apiFetch("/users/me", { next: { tags: ["me"] } }, MeSchema, token);
export const getUserProfile = (username: string, token: string | null) =>
apiFetch(`/users/${username}`, { next: { tags: [`profile:${username}`] } }, UserSchema, token);
export const getFollowersList = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/followers`,
{ next: { tags: [`profile:${username}`] } },
z.object({ total: z.number(), items: z.array(UserSchema) }),
token
);
export const getFollowingList = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/following`,
{ next: { tags: [`profile:${username}`] } },
z.object({ total: z.number(), items: z.array(UserSchema) }),
token
);
export const getTopFriends = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/top-friends`,
{ next: { tags: [`profile:${username}`] } },
z.object({ topFriends: z.array(z.string()) }),
token
);
export const getFeed = (token: string, page: number = 1, pageSize: number = 20) =>
apiFetch(
`/feed?page=${page}&per_page=${pageSize}`,
{ next: { tags: ["feed"] } },
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() })
.transform((d) => ({ ...d, totalPages: Math.ceil(d.total / d.per_page) })),
token
);
export const getUserThoughts = (username: string, token: string | null) =>
apiFetch(
`/users/${username}/thoughts`,
{ next: { tags: [`profile:${username}`] } },
z.object({ items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
token
);
export const getThoughtById = (thoughtId: string, token: string | null) =>
apiFetch(
`/thoughts/${thoughtId}`,
{ next: { tags: [`thought:${thoughtId}`] } },
ThoughtSchema,
token
);
export const getThoughtsByTag = (tagName: string, token: string | null) =>
apiFetch(
`/tags/${tagName}`,
{ next: { tags: [`tag:${tagName}`, "feed"] } },
z.object({ tag: z.string(), items: z.array(ThoughtSchema), total: z.number(), page: z.number(), per_page: z.number() }),
token
);
export const search = (query: string, token: string | null) =>
apiFetch(
`/search?q=${encodeURIComponent(query)}`,
{ next: { tags: ["search"] } },
SearchResultsSchema,
token
);
- Step 3: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 4: Commit
git add lib/api.ts
git commit -m "feat(frontend): add next.tags support to apiFetch; tag all GET fetches"
Task 2: Server Actions — thought mutations
Files:
-
Create:
app/actions/thoughts.ts -
Step 1: Create the file
// app/actions/thoughts.ts
"use server";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { createThought as apiCreateThought, deleteThought as apiDeleteThought, CreateThoughtSchema } from "@/lib/api";
import { z } from "zod";
async function getToken(): Promise<string> {
const token = (await cookies()).get("auth_token")?.value;
if (!token) throw new Error("Not authenticated");
return token;
}
export async function createThought(data: z.infer<typeof CreateThoughtSchema>) {
const token = await getToken();
const thought = await apiCreateThought(data, token);
revalidateTag("feed");
return thought;
}
export async function deleteThought(thoughtId: string) {
const token = await getToken();
await apiDeleteThought(thoughtId, token);
revalidateTag("feed");
revalidateTag(`thought:${thoughtId}`);
}
- Step 2: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 3: Commit
git add app/actions/thoughts.ts
git commit -m "feat(frontend): Server Actions for thought mutations"
Task 3: Server Actions — social mutations
Files:
-
Create:
app/actions/social.ts -
Step 1: Create the file
// app/actions/social.ts
"use server";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import {
followUser as apiFollowUser,
unfollowUser as apiUnfollowUser,
} from "@/lib/api";
async function getToken(): Promise<string> {
const token = (await cookies()).get("auth_token")?.value;
if (!token) throw new Error("Not authenticated");
return token;
}
export async function followUser(username: string) {
const token = await getToken();
await apiFollowUser(username, token);
revalidateTag(`profile:${username}`);
revalidateTag("feed");
}
export async function unfollowUser(username: string) {
const token = await getToken();
await apiUnfollowUser(username, token);
revalidateTag(`profile:${username}`);
revalidateTag("feed");
}
- Step 2: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 3: Commit
git add app/actions/social.ts
git commit -m "feat(frontend): Server Actions for social mutations"
Task 4: Server Actions — profile mutation
Files:
-
Create:
app/actions/profile.ts -
Step 1: Create the file
// app/actions/profile.ts
"use server";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { updateProfile as apiUpdateProfile, UpdateProfileSchema } from "@/lib/api";
import { z } from "zod";
async function getToken(): Promise<string> {
const token = (await cookies()).get("auth_token")?.value;
if (!token) throw new Error("Not authenticated");
return token;
}
export async function updateProfile(
username: string,
data: z.infer<typeof UpdateProfileSchema>
) {
const token = await getToken();
const updated = await apiUpdateProfile(data, token);
revalidateTag(`profile:${username}`);
revalidateTag("me");
return updated;
}
- Step 2: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 3: Commit
git add app/actions/profile.ts
git commit -m "feat(frontend): Server Action for profile mutation"
Task 5: Remove the author-profile waterfall
This is the biggest performance fix. Pages currently re-fetch getUserProfile for every unique author after the feed loads. Since thought.author already contains the data, we delete all of this.
Files:
- Modify:
components/thought-thread.tsx - Modify:
components/thought-card.tsx - Modify:
app/page.tsx - Modify:
app/users/[username]/page.tsx - Modify:
app/thoughts/[thoughtId]/page.tsx - Modify:
app/tags/[tagName]/page.tsx - Modify:
app/search/page.tsx
5a: Update ThoughtThread — remove authorDetails prop
- Step 1: Replace
components/thought-thread.tsx
// components/thought-thread.tsx
import { Me, ThoughtThread as ThoughtThreadType } from "@/lib/api";
import { ThoughtCard } from "./thought-card";
interface ThoughtThreadProps {
thought: ThoughtThreadType;
currentUser: Me | null;
isReply?: boolean;
}
export function ThoughtThread({
thought,
currentUser,
isReply = false,
}: ThoughtThreadProps) {
return (
<div id={`thought-thread-${thought.id}`} className="flex flex-col gap-0">
<ThoughtCard
thought={thought}
currentUser={currentUser}
isReply={isReply}
/>
{thought.replies.length > 0 && (
<div
id={`thought-thread-${thought.id}__replies`}
className="pl-6 border-l-2 border-primary border-dashed ml-6 flex flex-col gap-4 pt-4"
>
{thought.replies.map((reply) => (
<ThoughtThread
key={reply.id}
thought={reply}
currentUser={currentUser}
isReply={true}
/>
))}
</div>
)}
</div>
);
}
5b: Update ThoughtCard — use thought.author directly, call Server Action for delete
- Step 2: Replace
components/thought-card.tsx
// components/thought-card.tsx
"use client";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { UserAvatar } from "./user-avatar";
import { Me, Thought } from "@/lib/api";
import { format, formatDistanceToNow } from "date-fns";
import { useAuth } from "@/hooks/use-auth";
import { useState } from "react";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { CornerUpLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
import { ReplyForm } from "@/components/reply-form";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { deleteThought } from "@/app/actions/thoughts";
interface ThoughtCardProps {
thought: Thought;
currentUser: Me | null;
isReply?: boolean;
}
export function ThoughtCard({
thought,
currentUser,
isReply = false,
}: ThoughtCardProps) {
const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false);
const { token } = useAuth();
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), { addSuffix: true });
const { author } = thought;
const isAuthor = currentUser?.username === author.username;
const handleDelete = async () => {
try {
await deleteThought(thought.id);
toast.success("Thought deleted successfully.");
} catch {
toast.error("Failed to delete thought.");
} finally {
setIsAlertOpen(false);
}
};
return (
<>
<div
id={thought.id}
className={cn(
"bg-transparent backdrop-blur-lg shadow-fa-md rounded-xl overflow-hidden glossy-effect bottom",
isReply ? "bg-white/80 glass-effect glossy-effect bottom shadow-fa-sm p-2" : ""
)}
>
{thought.replyToId && isReply && (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<CornerUpLeft className="h-4 w-4 text-primary/70" />
<span>
Replying to{" "}
<Link href={`#${thought.replyToId}`} className="hover:underline text-primary text-shadow-sm">
parent thought
</Link>
</span>
</div>
)}
{!thought.replyToId && thought.replyToUrl && (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<CornerUpLeft className="h-4 w-4 text-primary/70" />
<span>
Replying to{" "}
<a href={thought.replyToUrl} target="_blank" rel="noopener noreferrer" className="hover:underline text-primary text-shadow-sm">
original post ↗
</a>
</span>
</div>
)}
</div>
<Card className="mt-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Link href={`/users/${author.username}`} className="flex items-center gap-4 text-shadow-md">
<UserAvatar src={author.avatarUrl} alt={author.displayName || author.username} />
<div className="flex flex-col">
<span className="font-bold">{author.displayName || author.username}</span>
<time
dateTime={new Date(thought.createdAt).toISOString()}
title={format(new Date(thought.createdAt), "PPP p")}
className="text-sm text-muted-foreground text-shadow-sm"
>
{timeAgo}
</time>
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-2 rounded-full hover:bg-accent">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{isAuthor && (
<DropdownMenuItem className="text-destructive" onSelect={() => setIsAlertOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
)}
<DropdownMenuItem>
<Link href={`/thoughts/${thought.id}`} className="flex gap-2">
<MessageSquare className="mr-2 h-4 w-4" />
View
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
{author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm">{thought.content}</p>
) : (
<div
className="text-sm break-words [&_a]:underline [&_a]:text-primary [&_p]:mb-2 [&_.media-notice]:text-muted-foreground [&_.media-notice]:italic"
dangerouslySetInnerHTML={{
__html: thought.content.trim() || '<p class="media-notice">📎 Media attachment — not supported</p>',
}}
/>
)}
</CardContent>
{token && (
<CardFooter className="border-t px-4 pt-2 pb-2 border-border/50">
<Button variant="ghost" size="sm" onClick={() => setIsReplyOpen(!isReplyOpen)}>
<MessageSquare className="mr-2 h-4 w-4" />
Reply
</Button>
</CardFooter>
)}
{isReplyOpen && (
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
<ReplyForm parentThoughtId={thought.id} onReplySuccess={() => setIsReplyOpen(false)} />
</div>
)}
</Card>
<AlertDialog open={isAlertOpen} onOpenChange={setIsAlertOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your thought.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
5c: Remove waterfall from app/page.tsx
- Step 3: Update
app/page.tsx— delete lines 65–74 (thegetUserProfilewaterfall) and theauthorDetailsmap
Replace the FeedPage function body with:
async function FeedPage({
token,
searchParams,
}: {
token: string;
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page ?? "1", 10);
const [feedData, me] = await Promise.all([
getFeed(token, page).catch(() => null),
getMe(token).catch(() => null) as Promise<Me | null>,
]);
if (!feedData || !me) {
redirect("/login");
}
const { items: allThoughts, totalPages } = feedData;
const thoughtThreads = buildThoughtThreads(allThoughts);
const friends = (await getFriends(token)).users.map((user) => user.username);
const topFriendsData = me
? await getTopFriends(me.username, token).catch(() => ({ topFriends: [] }))
: { topFriends: [] };
const shouldDisplayTopFriends = topFriendsData.topFriends.length > 0;
return (
<div className="container mx-auto max-w-6xl p-4 sm:p-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
<h2 className="text-lg font-semibold">Filters & Sorting</h2>
<p className="text-sm text-muted-foreground">Coming soon...</p>
</div>
</aside>
<main className="col-span-1 lg:col-span-2 space-y-6">
<header className="mb-6">
<h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
</header>
<PostThoughtForm />
<div className="block lg:hidden space-y-6">
<PopularTags />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
)}
{!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends} />
)}
<UsersCount />
</div>
<div className="space-y-6">
{thoughtThreads.map((thought) => (
<ThoughtThread
key={thought.id}
thought={thought}
currentUser={me}
/>
))}
{thoughtThreads.length === 0 && (
<p className="text-center text-muted-foreground pt-8">
Your feed is empty. Follow some users to see their thoughts!
</p>
)}
</div>
<PaginationNav
page={page}
totalPages={totalPages}
buildHref={(p) => `/?page=${p}`}
/>
</main>
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6">
<PopularTags />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={topFriendsData.topFriends} />
)}
{!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends} />
)}
<UsersCount />
</div>
</aside>
</div>
</div>
);
}
Also remove getUserProfile and User from the import at the top of app/page.tsx.
5d: Remove waterfall from remaining pages
- Step 4: Update
app/users/[username]/page.tsx
Read the full file first. Find the block that:
- Collects unique author usernames from
thoughts - Calls
getUserProfilefor each - Builds an
authorDetailsMap
Delete that entire block. Remove authorDetails from all ThoughtThread usages in this file. Remove getUserProfile (for author fetching, not for the profile page header — keep the one that fetches the page subject's profile) and User from the imports if they are no longer needed.
The ThoughtThread components in this file should be called as:
<ThoughtThread thought={thread} currentUser={me} />
(no authorDetails prop)
- Step 5: Update
app/thoughts/[thoughtId]/page.tsx
Same pattern: find and delete the author waterfall, remove authorDetails from ThoughtThread calls.
- Step 6: Update
app/tags/[tagName]/page.tsx
Same pattern.
- Step 7: Update
app/search/page.tsx
Same pattern.
- Step 8: Type-check
npx tsc --noEmit
Expected: 0 errors. Fix any remaining authorDetails references until clean.
- Step 9: Verify in browser
Run npm run dev. Open the feed page. Check the Network tab — you should see no getUserProfile calls after the feed loads. Author avatars should still display correctly (from thought.author.avatarUrl).
- Step 10: Commit
git add components/thought-thread.tsx components/thought-card.tsx app/page.tsx \
app/users app/thoughts app/tags app/search
git commit -m "perf(frontend): eliminate author profile waterfall — use thought.author directly"
Task 6: Shared primitives — EmptyState and LoadingSkeleton
Files:
-
Create:
components/empty-state.tsx -
Create:
components/loading-skeleton.tsx -
Step 1: Create
components/empty-state.tsx
// components/empty-state.tsx
interface EmptyStateProps {
message: string
className?: string
}
export function EmptyState({ message, className }: EmptyStateProps) {
return (
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
{message}
</p>
)
}
- Step 2: Create
components/loading-skeleton.tsx
// components/loading-skeleton.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export function ThoughtSkeleton() {
return (
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</CardContent>
</Card>
)
}
export function ProfileSkeleton() {
return (
<Card>
<CardContent className="pt-6 flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</CardContent>
</Card>
)
}
- Step 3: Replace copy-pasted empty state blocks
In app/page.tsx, replace:
<p className="text-center text-muted-foreground pt-8">
Your feed is empty. Follow some users to see their thoughts!
</p>
with:
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
Add import { EmptyState } from "@/components/empty-state" to the file.
Repeat for app/users/[username]/page.tsx, app/tags/[tagName]/page.tsx, app/search/page.tsx — find each hard-coded empty state paragraph and replace with <EmptyState message="..." />.
- Step 4: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 5: Commit
git add components/empty-state.tsx components/loading-skeleton.tsx \
app/page.tsx app/users app/tags app/search
git commit -m "refactor(frontend): shared EmptyState and LoadingSkeleton primitives"
Task 7: Unified ThoughtForm — replaces PostThoughtForm and ReplyForm
Files:
-
Create:
components/thought-form.tsx -
Delete:
components/post-thought-form.tsx -
Delete:
components/reply-form.tsx -
Modify:
app/page.tsx(update import) -
Modify:
components/thought-card.tsx(update import) -
Step 1: Create
components/thought-form.tsx
// components/thought-form.tsx
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
Form,
FormField,
FormItem,
FormControl,
FormMessage,
} from "@/components/ui/form"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { CreateThoughtSchema } from "@/lib/api"
import { useAuth } from "@/hooks/use-auth"
import { toast } from "sonner"
import { Globe, Lock, Users } from "lucide-react"
import { useState } from "react"
import { Confetti } from "./confetti"
import { createThought } from "@/app/actions/thoughts"
interface ThoughtFormProps {
/** Set to the parent thought ID when composing a reply. */
replyToId?: string
/** Called after successful submit (e.g. close the reply panel). */
onSuccess?: () => void
/** Whether to wrap the form in a Card. Default: true for top-level, false for replies. */
card?: boolean
}
export function ThoughtForm({ replyToId, onSuccess, card = !replyToId }: ThoughtFormProps) {
const { token } = useAuth()
const [showConfetti, setShowConfetti] = useState(false)
const form = useForm<z.infer<typeof CreateThoughtSchema>>({
resolver: zodResolver(CreateThoughtSchema),
defaultValues: {
content: "",
visibility: "public",
...(replyToId ? { inReplyToId: replyToId } : {}),
},
})
async function onSubmit(values: z.infer<typeof CreateThoughtSchema>) {
if (!token) {
toast.error("You must be logged in.")
return
}
try {
await createThought(values)
toast.success(replyToId ? "Reply posted!" : "Thought posted!")
setShowConfetti(true)
form.reset()
onSuccess?.()
} catch {
toast.error(replyToId ? "Failed to post reply." : "Failed to post thought.")
}
}
const inner = (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
placeholder={replyToId ? "Post your reply..." : "What's on your mind?"}
className={`resize-none ${replyToId ? "bg-white glass-effect glossy-effect bottom shadow-fa-sm" : ""}`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className={`flex ${replyToId ? "justify-end gap-2" : "justify-between items-center"}`}>
{!replyToId && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Visibility" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="public">
<div className="flex items-center gap-2"><Globe className="h-4 w-4" /> Public</div>
</SelectItem>
<SelectItem value="followers">
<div className="flex items-center gap-2"><Users className="h-4 w-4" /> Followers</div>
</SelectItem>
<SelectItem value="unlisted">
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Unlisted</div>
</SelectItem>
<SelectItem value="direct">
<div className="flex items-center gap-2"><Lock className="h-4 w-4" /> Direct</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
)}
{replyToId && (
<Button type="button" variant="ghost" onClick={onSuccess}>
Cancel
</Button>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting
? replyToId ? "Replying..." : "Posting..."
: replyToId ? "Reply" : "Post Thought"}
</Button>
</div>
</form>
</Form>
)
return (
<>
<Confetti fire={showConfetti} onComplete={() => setShowConfetti(false)} />
{card ? <Card><CardContent className="p-4">{inner}</CardContent></Card> : <div className="space-y-2 p-4">{inner}</div>}
</>
)
}
- Step 2: Update
app/page.tsx— swap import
Replace:
import { PostThoughtForm } from "@/components/post-thought-form";
with:
import { ThoughtForm } from "@/components/thought-form";
Replace <PostThoughtForm /> with <ThoughtForm />.
- Step 3: Update
components/thought-card.tsx— swap ReplyForm import
Replace:
import { ReplyForm } from "@/components/reply-form";
with:
import { ThoughtForm } from "@/components/thought-form";
Replace:
<ReplyForm
parentThoughtId={thought.id}
onReplySuccess={() => setIsReplyOpen(false)}
/>
with:
<ThoughtForm
replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)}
/>
- Step 4: Delete the old files
rm components/post-thought-form.tsx components/reply-form.tsx
- Step 5: Search for any remaining imports of the deleted files
grep -r "post-thought-form\|reply-form" --include="*.tsx" --include="*.ts" .
Expected: no results. If any remain, update those imports to use ThoughtForm.
- Step 6: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 7: Verify in browser
Open the feed page. Post a thought — it should appear without a page reload (Server Action + revalidateTag('feed')). Open a thought thread. Reply — the form should close and the reply count update.
- Step 8: Commit
git add components/thought-form.tsx components/thought-card.tsx app/page.tsx
git commit -m "refactor(frontend): unified ThoughtForm replaces PostThoughtForm and ReplyForm"
Task 8: FollowButton — useOptimistic + Server Action
Replace the useState + router.refresh() pattern with useOptimistic (React 19) and the Server Action from Task 3.
Files:
-
Modify:
components/follow-button.tsx -
Step 1: Replace
components/follow-button.tsx
// components/follow-button.tsx
"use client"
import { useOptimistic } from "react"
import { followUser, unfollowUser } from "@/app/actions/social"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { UserPlus, UserMinus } from "lucide-react"
interface FollowButtonProps {
username: string
isInitiallyFollowing: boolean
}
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
async function handleClick() {
const next = !optimisticFollowing
setOptimisticFollowing(next)
try {
await (next ? followUser(username) : unfollowUser(username))
} catch {
setOptimisticFollowing(!next) // revert
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
}
}
return (
<Button
onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"}
data-following={optimisticFollowing}
>
{optimisticFollowing ? (
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
) : (
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
)
}
- Step 2: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 3: Verify in browser
Go to a user profile page. Click Follow — button should toggle instantly. The profile follower count should update after the Server Action completes (because revalidateTag fires).
- Step 4: Commit
git add components/follow-button.tsx
git commit -m "refactor(frontend): FollowButton — useOptimistic + Server Action, remove router.refresh()"
Task 9: Update edit-profile-form to use Server Action
Files:
-
Modify:
components/edit-profile-form.tsx -
Step 1: Read the current file
cat components/edit-profile-form.tsx
- Step 2: Replace the mutation call
Find the submit handler. It currently calls updateProfile(data, token) from @/lib/api then router.refresh().
Change the import: remove updateProfile from @/lib/api. Add:
import { updateProfile } from "@/app/actions/profile"
Remove useRouter and const router = useRouter().
In the submit handler, find await updateProfile(data, token) and replace with:
const me = await getMe(token!) // still needed to get username for revalidation
await updateProfile(me.username, data)
Or if username is already available as a prop, pass it to the Server Action directly.
Remove the router.refresh() call — the Server Action handles invalidation via revalidateTag('me') and revalidateTag('profile:${username}').
- Step 3: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 4: Commit
git add components/edit-profile-form.tsx
git commit -m "refactor(frontend): edit-profile-form — use Server Action, remove router.refresh()"
Task 10: Split RemoteUserProfile
The 296-line components/remote-user-profile.tsx mixes profile display, tab switching, and follower/following list fetching. Split into focused sub-components.
Files:
-
Create:
components/remote-user-profile/profile-card.tsx -
Create:
components/remote-user-profile/connections.tsx -
Create:
components/remote-user-profile/index.tsx -
Delete:
components/remote-user-profile.tsx -
Step 1: Read the current file in full
cat components/remote-user-profile.tsx
- Step 2: Create
components/remote-user-profile/profile-card.tsx
Extract the avatar, display name, handle, bio, follower/following count display, and external link into this component. It receives the RemoteActor data as a prop and renders it — no state, no API calls.
// components/remote-user-profile/profile-card.tsx
import { RemoteActor } from "@/lib/api"
import { UserAvatar } from "@/components/user-avatar"
import { ExternalLink } from "lucide-react"
interface ProfileCardProps {
actor: RemoteActor
}
export function ProfileCard({ actor }: ProfileCardProps) {
return (
<div className="space-y-4">
<div className="flex items-start gap-4">
<UserAvatar src={actor.avatarUrl} alt={actor.displayName || actor.handle} className="h-16 w-16" />
<div className="flex-1 min-w-0">
<h2 className="font-bold text-xl truncate">{actor.displayName || actor.handle}</h2>
<p className="text-sm text-muted-foreground truncate">@{actor.handle}</p>
{actor.url && (
<a href={actor.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-primary hover:underline mt-1">
<ExternalLink className="h-3 w-3" /> View profile
</a>
)}
</div>
</div>
{actor.bio && <p className="text-sm text-muted-foreground">{actor.bio}</p>}
</div>
)
}
- Step 3: Create
components/remote-user-profile/connections.tsx
Extract the followers/following list fetching and rendering into this component. It receives the handle and token, manages its own pagination state.
Move the follower list pagination logic (useState for page, the fetch calls for getActorFollowers/getActorFollowing, the list rendering) from the monolith into this component:
// components/remote-user-profile/connections.tsx
"use client"
import { useState, useEffect } from "react"
import { ActorConnection, getActorFollowers, getActorFollowing } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { UserAvatar } from "@/components/user-avatar"
interface ConnectionsProps {
handle: string
token: string | null
type: "followers" | "following"
}
export function Connections({ handle, token, type }: ConnectionsProps) {
const [items, setItems] = useState<ActorConnection[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
const fetch = type === "followers" ? getActorFollowers : getActorFollowing
fetch(handle, page, token)
.then((data) => {
setItems((prev) => (page === 1 ? data.items : [...prev, ...data.items]))
setHasMore(data.hasMore)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [handle, token, type, page])
return (
<div className="space-y-3">
{items.map((item) => (
<a key={item.handle} href={item.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:opacity-80">
<UserAvatar src={item.avatarUrl} alt={item.displayName || item.handle} className="h-8 w-8" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.displayName || item.handle}</p>
<p className="text-xs text-muted-foreground truncate">@{item.handle}</p>
</div>
</a>
))}
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{hasMore && !loading && (
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)}>
Load more
</Button>
)}
{!loading && items.length === 0 && (
<p className="text-sm text-muted-foreground">No {type} yet.</p>
)}
</div>
)
}
- Step 4: Create
components/remote-user-profile/index.tsx
This component owns only tab state. It delegates all display to ProfileCard and Connections.
Look at the current remote-user-profile.tsx for the Tab structure and adapt it using the new sub-components:
// components/remote-user-profile/index.tsx
"use client"
import { useState } from "react"
import { RemoteActor } from "@/lib/api"
import { ProfileCard } from "./profile-card"
import { Connections } from "./connections"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface RemoteUserProfileProps {
actor: RemoteActor
token: string | null
}
export function RemoteUserProfile({ actor, token }: RemoteUserProfileProps) {
return (
<div className="space-y-6">
<ProfileCard actor={actor} />
<Tabs defaultValue="followers">
<TabsList>
<TabsTrigger value="followers">Followers</TabsTrigger>
<TabsTrigger value="following">Following</TabsTrigger>
</TabsList>
<TabsContent value="followers">
<Connections handle={actor.handle} token={token} type="followers" />
</TabsContent>
<TabsContent value="following">
<Connections handle={actor.handle} token={token} type="following" />
</TabsContent>
</Tabs>
</div>
)
}
- Step 5: Update import in
app/remote-actor/page.tsx
Find: import { RemoteUserProfile } from "@/components/remote-user-profile"
Replace: import { RemoteUserProfile } from "@/components/remote-user-profile/index"
Or just: the import path stays the same if the directory index.tsx resolves automatically.
- Step 6: Delete the old file
rm components/remote-user-profile.tsx
- Step 7: Type-check
npx tsc --noEmit
Expected: 0 errors.
- Step 8: Verify in browser
Go to /remote-actor?handle=@someone@instance.social. Profile should display. Followers/following tabs should load.
- Step 9: Commit
git add components/remote-user-profile/ app/remote-actor
git commit -m "refactor(frontend): split RemoteUserProfile into ProfileCard + Connections"
Self-Review Checklist (run after all tasks)
npx tsc --noEmitpasses with 0 errorsgrep -r "router\.refresh" --include="*.tsx" --include="*.ts" .returns no resultsgrep -r "authorDetails" --include="*.tsx" --include="*.ts" .returns no resultsgrep -r "getUserProfile" --include="*.tsx" app/returns only legitimate uses (profile page metadata generation), not author waterfall fetches- Feed page Network tab shows no
getUserProfilecalls after initial load - Posting a thought refreshes the feed without
router.refresh() - Following a user updates the button state immediately (optimistic) and the profile refreshes
Final commit message (after all tasks)
feat(frontend): performance + DRY + composition overhaul
- Eliminate author profile waterfall (thought.author used directly)
- Server Actions + revalidateTag replace scattered router.refresh()
- useOptimistic on FollowButton for instant feedback
- ThoughtForm unifies PostThoughtForm and ReplyForm
- EmptyState and LoadingSkeleton shared primitives
- RemoteUserProfile split into ProfileCard + Connections