# Frontend Overhaul Design **Date:** 2026-05-15 **Status:** Approved ## Problem The backend `/feed` endpoint responds in ~10ms, but the frontend renders the page noticeably later. Three root causes: 1. **Author profile waterfall** — every page that displays thoughts fetches N separate `getUserProfile` calls after the main feed query, sequentially. 4+ pages duplicate this pattern verbatim. 2. **Scattered cache invalidation** — 5 isolated `router.refresh()` calls with no shared abstraction. Full page re-render on every mutation regardless of what actually changed. 3. **Broken composition** — `authorDetails: Map` prop-drilled 4+ levels. Three components over 200 lines. Empty/loading states copy-pasted 4+ times each. `PostThoughtForm` and `ReplyForm` are near-identical duplicates. ## Approach **Server Actions + `revalidateTag`** — idiomatic Next.js 15 + React 19 solution. - Backend embeds author data in all response types → waterfall eliminated at the source - Fetch cache tagged by data domain → mutations revalidate exactly what changed, not the whole page - Server Actions replace client-side fetch + `router.refresh()` → one invalidation point per mutation - `useOptimistic` (React 19) → instant UI feedback for toggle interactions - Component splits and shared primitives → composition fixed No new dependencies added. --- ## Section 1: Backend — Embed Author Data Everywhere ### New type in `crates/api-types/` ```rust pub struct AuthorResponse { pub id: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, } ``` ### Response types updated | Response type | Change | |---|---| | `ThoughtResponse` | Add `author: AuthorResponse` | | `NotificationResponse` | Add `actor: AuthorResponse` | | `BoostResponse` / `LikeResponse` | Add `actor: AuthorResponse` if referencing a user | ### Architecture constraints - Business logic stays in application use cases, not handlers. - **Feed/list queries:** `PgFeedRepository` already joins the users table. `row_to_entry` populates author fields from the existing join — no extra queries. - **Individual thought lookups:** Use cases that return a single thought compose `ThoughtRepository + UserReader` to produce a `ThoughtWithAuthor` output struct. The use case already takes both ports. - **Handlers** only map: `ThoughtWithAuthor → ThoughtResponse { author: AuthorResponse }`. No DB access. ### Frontend impact `lib/api.ts` Zod schemas updated to match. The `authorDetails: Map` pattern, all `getUserProfile` calls inside page components, and all `Promise.all([...getUserProfile])` waterfalls are deleted. --- ## Section 2: Frontend — Tagged Fetch Cache Replace bare `fetch()` calls with tagged Next.js fetch so `revalidateTag` can target them precisely. ### `apiFetch` signature addition ```ts apiFetch(path, token, options?: RequestInit & { next?: NextFetchRequestConfig }) ``` The `next` field passes through to `fetch()`. No new dependencies. ### Tag taxonomy | Tag | Invalidated when | |---|---| | `feed` | thought posted, deleted, edited, boosted | | `profile:{username}` | profile edited, follow/unfollow of that user | | `thoughts:{id}` | thought edited, deleted, replied to | | `tags:{name}` | new thought containing that tag | | `notifications` | any interaction received | ### Usage ```ts // lib/api.ts const feed = await apiFetch('/feed', token, { next: { tags: ['feed'] } }) const profile = await apiFetch(`/users/${username}`, token, { next: { tags: [`profile:${username}`] } }) ``` --- ## Section 3: Mutation Layer — Server Actions All mutations become Server Actions in `app/actions/`. Each action calls the backend, then revalidates exactly the affected tags. ### File layout ``` app/actions/ thoughts.ts — createThought, deleteThought, editThought social.ts — followUser, unfollowUser, likeThought, boostThought profile.ts — updateProfile ``` ### Examples ```ts // app/actions/thoughts.ts 'use server' import { revalidateTag } from 'next/cache' export async function createThought(formData: FormData) { const token = await getToken() await apiFetch('/thoughts', token, { method: 'POST', body: parseFormData(formData) }) revalidateTag('feed') } export async function deleteThought(thoughtId: string, authorUsername: string) { const token = await getToken() await apiFetch(`/thoughts/${thoughtId}`, token, { method: 'DELETE' }) revalidateTag('feed') revalidateTag(`thoughts:${thoughtId}`) revalidateTag(`profile:${authorUsername}`) } ``` ```ts // app/actions/social.ts 'use server' export async function followUser(username: string) { const token = await getToken() await apiFetch(`/users/${username}/follow`, token, { method: 'POST' }) revalidateTag(`profile:${username}`) revalidateTag('feed') } export async function likeThought(thoughtId: string) { const token = await getToken() await apiFetch(`/thoughts/${thoughtId}/like`, token, { method: 'POST' }) revalidateTag(`thoughts:${thoughtId}`) revalidateTag('feed') } ``` ### Migration Components drop `router.refresh()` and the `useRouter` import entirely. They call the Server Action directly (or pass it as a prop). The `router` dependency disappears from all mutation components. --- ## Section 4: Optimistic Updates — `useOptimistic` React 19's `useOptimistic` used for toggle interactions where latency is most noticeable: like, boost, follow. ### Like/boost (in `ThoughtCardActions`) ```tsx 'use client' export function ThoughtActions({ thought }: { thought: ThoughtResponse }) { const [optimisticLiked, addOptimisticLike] = useOptimistic(thought.liked_by_viewer) const [optimisticLikes, addOptimisticCount] = useOptimistic(thought.likes_count) async function handleLike() { addOptimisticLike(!optimisticLiked) addOptimisticCount(optimisticLiked ? optimisticLikes - 1 : optimisticLikes + 1) await likeThought(thought.id) } return ( ) } ``` ### Follow button ```tsx 'use client' export function FollowButton({ username, initialFollowing }: Props) { const [optimisticFollowing, addOptimistic] = useOptimistic(initialFollowing) async function handleFollow() { addOptimistic(!optimisticFollowing) await (optimisticFollowing ? unfollowUser(username) : followUser(username)) } return } ``` **Scope:** optimistic updates for like, boost, follow only. `createThought` and `deleteThought` do not get optimistic treatment — tagged cache revalidation is fast enough. --- ## Section 5: Composition ### `ThoughtCard` split ``` components/thought-card/ index.tsx — assembles sub-components, no logic header.tsx — avatar, username, display name, timestamp body.tsx — content, hashtag links, content warning toggle actions.tsx — like, boost, reply, delete; owns useOptimistic (client component) ``` Author data comes from `ThoughtResponse.author` directly. No `authorDetails` map, no prop drilling. ### Unified `ThoughtForm` Replaces `PostThoughtForm` and `ReplyForm` (near-identical duplicates). ```tsx // components/thought-form.tsx type Props = { action: (formData: FormData) => Promise // Server Action passed in placeholder?: string replyTo?: string } ``` Call sites: ```tsx ``` `PostThoughtForm` and `ReplyForm` files are deleted. ### Shared primitives ```tsx // components/empty-state.tsx export function EmptyState({ message }: { message: string }) { ... } // components/loading-skeleton.tsx export function ThoughtSkeleton() { ... } export function ProfileSkeleton() { ... } ``` 4 copy-pasted empty state blocks replaced by ``. Repeated loading card JSX replaced by ``. ### `RemoteUserProfile` split ``` components/remote-user-profile/ index.tsx — tab state only (client) profile-card.tsx — avatar, bio, stats (server) connections.tsx — followers/following lists with Suspense boundary (client) ``` --- ## Files Changed ### Backend (`crates/`) | File | Change | |---|---| | `crates/api-types/src/responses.rs` | Add `AuthorResponse`; embed in `ThoughtResponse`, `NotificationResponse` | | `crates/adapters/postgres/src/feed.rs` | Enrich `row_to_entry` with author columns from existing join | | `crates/application/src/use_cases/thoughts.rs` | Return `ThoughtWithAuthor` from relevant use cases | | `crates/presentation/src/handlers/thoughts.rs` | Map `ThoughtWithAuthor → ThoughtResponse`; remove secondary user fetches | | `crates/presentation/src/handlers/feed.rs` | Same mapping update | | `crates/presentation/src/handlers/notifications.rs` | Embed actor in notification responses | ### Frontend (`thoughts-frontend/`) | File | Change | |---|---| | `lib/api.ts` | Add `next` option to `apiFetch`; update Zod schemas with `AuthorResponse` | | `app/actions/thoughts.ts` | New — Server Actions for thought mutations | | `app/actions/social.ts` | New — Server Actions for social interactions | | `app/actions/profile.ts` | New — Server Actions for profile mutations | | `app/page.tsx` | Remove author fetch waterfall; use tagged fetch | | `app/users/[username]/page.tsx` | Same | | `app/thoughts/[thoughtId]/page.tsx` | Same | | `app/tags/[tagName]/page.tsx` | Same | | `app/search/page.tsx` | Same | | `components/thought-card/` | Split into header/body/actions | | `components/thought-form.tsx` | New unified form | | `components/post-thought-form.tsx` | Deleted | | `components/reply-form.tsx` | Deleted | | `components/empty-state.tsx` | New shared primitive | | `components/loading-skeleton.tsx` | New shared primitive | | `components/follow-button.tsx` | Remove `router.refresh()`; use Server Action + `useOptimistic` | | `components/remote-user-profile/` | Split into profile-card/connections | --- ## What This Does Not Cover - Streaming / Suspense boundaries between sidebar widgets (future, lower priority once waterfall is gone) - Search result caching (search is user-input-driven; less predictable tag invalidation) - Pagination beyond the current page-based approach