Files
thoughts/docs/superpowers/specs/2026-05-15-frontend-overhaul-design.md

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:

  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 compositionauthorDetails: Map<string, {...}> 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/

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: 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<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