# 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 in `lib/api.ts`. `options` is `RequestInit`. - `ThoughtResponse` (Zod: `ThoughtSchema`) already has `author: UserSchema` embedded — `thought.author.username`, `thought.author.avatarUrl`, `thought.author.displayName` all exist on every thought. - `ThoughtThread` component (not the type) re-fetches the avatar via `authorDetails.get(username)` as an override — this is now unnecessary. - Run the dev server with `npm run dev` (or `bun dev`) from `thoughts-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 `apiFetch` signature to accept Next.js cache options** In `lib/api.ts`, change lines 154–188 (`apiFetch` function). Replace the function with: ```ts type ApiFetchOptions = Omit & { next?: { tags?: string[]; revalidate?: number | false } } async function apiFetch( endpoint: string, options: ApiFetchOptions = {}, schema: z.ZodType, token?: string | null ): Promise { if (!API_BASE_URL) { throw new Error("API_BASE_URL is not defined"); } const { next, ...restOptions } = options; const headers: Record = { "Content-Type": "application/json", ...(restOptions.headers as Record), }; 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`: ```ts 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** ```bash npx tsc --noEmit ``` Expected: 0 errors. - [ ] **Step 4: Commit** ```bash 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** ```ts // 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 { const token = (await cookies()).get("auth_token")?.value; if (!token) throw new Error("Not authenticated"); return token; } export async function createThought(data: z.infer) { 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** ```bash npx tsc --noEmit ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash 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** ```ts // 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 { 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** ```bash npx tsc --noEmit ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash 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** ```ts // 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 { 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 ) { const token = await getToken(); const updated = await apiUpdateProfile(data, token); revalidateTag(`profile:${username}`); revalidateTag("me"); return updated; } ``` - [ ] **Step 2: Type-check** ```bash npx tsc --noEmit ``` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash 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`** ```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 (
{thought.replies.length > 0 && (
{thought.replies.map((reply) => ( ))}
)}
); } ``` ### 5b: Update `ThoughtCard` — use `thought.author` directly, call Server Action for delete - [ ] **Step 2: Replace `components/thought-card.tsx`** ```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 ( <>
{thought.replyToId && isReply && (
Replying to{" "} parent thought
)} {!thought.replyToId && thought.replyToUrl && (
Replying to{" "} original post ↗
)}
{author.displayName || author.username}
{isAuthor && ( setIsAlertOpen(true)}> Delete )} View
{author.local ? (

{thought.content}

) : (
📎 Media attachment — not supported

', }} /> )} {token && ( )} {isReplyOpen && (
setIsReplyOpen(false)} />
)} Are you sure? This action cannot be undone. This will permanently delete your thought. Cancel Delete ); } ``` ### 5c: Remove waterfall from `app/page.tsx` - [ ] **Step 3: Update `app/page.tsx` — delete lines 65–74 (the `getUserProfile` waterfall) and the `authorDetails` map** Replace the `FeedPage` function body with: ```tsx 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, ]); 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 (

Your Feed

{shouldDisplayTopFriends && ( )} {!shouldDisplayTopFriends && token && friends.length > 0 && ( )}
{thoughtThreads.map((thought) => ( ))} {thoughtThreads.length === 0 && (

Your feed is empty. Follow some users to see their thoughts!

)}
`/?page=${p}`} />
); } ``` 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: 1. Collects unique author usernames from `thoughts` 2. Calls `getUserProfile` for each 3. Builds an `authorDetails` Map 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: ```tsx ``` (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** ```bash 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** ```bash 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`** ```tsx // components/empty-state.tsx interface EmptyStateProps { message: string className?: string } export function EmptyState({ message, className }: EmptyStateProps) { return (

{message}

) } ``` - [ ] **Step 2: Create `components/loading-skeleton.tsx`** ```tsx // components/loading-skeleton.tsx import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" export function ThoughtSkeleton() { return (
) } export function ProfileSkeleton() { return (
) } ``` - [ ] **Step 3: Replace copy-pasted empty state blocks** In `app/page.tsx`, replace: ```tsx

Your feed is empty. Follow some users to see their thoughts!

``` with: ```tsx ``` 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 ``. - [ ] **Step 4: Type-check** ```bash npx tsc --noEmit ``` Expected: 0 errors. - [ ] **Step 5: Commit** ```bash 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`** ```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>({ resolver: zodResolver(CreateThoughtSchema), defaultValues: { content: "", visibility: "public", ...(replyToId ? { inReplyToId: replyToId } : {}), }, }) async function onSubmit(values: z.infer) { 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 = (
(