10 KiB
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:
- Author profile waterfall — every page that displays thoughts fetches N separate
getUserProfilecalls after the main feed query, sequentially. 4+ pages duplicate this pattern verbatim. - Scattered cache invalidation — 5 isolated
router.refresh()calls with no shared abstraction. Full page re-render on every mutation regardless of what actually changed. - Broken composition —
authorDetails: Map<string, {...}>prop-drilled 4+ levels. Three components over 200 lines. Empty/loading states copy-pasted 4+ times each.PostThoughtFormandReplyFormare 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/
pub struct AuthorResponse {
pub id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
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:
PgFeedRepositoryalready joins the users table.row_to_entrypopulates author fields from the existing join — no extra queries. - Individual thought lookups: Use cases that return a single thought compose
ThoughtRepository + UserReaderto produce aThoughtWithAuthoroutput 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<string, {...}> 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
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
// 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
// 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}`)
}
// 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)
'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 (
<button onClick={handleLike}>
{optimisticLiked ? '♥' : '♡'} {optimisticLikes}
</button>
)
}
Follow button
'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 <button onClick={handleFollow}>{optimisticFollowing ? 'Unfollow' : 'Follow'}</button>
}
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).
// components/thought-form.tsx
type Props = {
action: (formData: FormData) => Promise<void> // Server Action passed in
placeholder?: string
replyTo?: string
}
Call sites:
<ThoughtForm action={createThought} placeholder="What's on your mind?" />
<ThoughtForm action={replyToThought.bind(null, thoughtId)} replyTo={author} />
PostThoughtForm and ReplyForm files are deleted.
Shared primitives
// 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 <EmptyState message="No thoughts yet." />. Repeated loading card JSX replaced by <ThoughtSkeleton />.
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