# Pinterest โ Frontend Mini LLD
Framework: Next.js (App Router) with React Server Components + Suspense streaming
Scope: Search page, Pin Creation page, Masonry grid, Suggestions, Image loading, Pin detail (minimal)
Companion doc: FRONTEND_HLD.md โ high-level design (RADIO): requirements, architecture, data model, interface (API), optimizations & deep dive
# 1. Route Structure
app/
โโโ layout.tsx โ Root layout โ nav only (no auth provider in case study)
โโโ loading.tsx โ Root Suspense skeleton
โ
โโโ search/
โ โโโ page.tsx โ Server Component โ fetches initial ES results
โ โโโ loading.tsx โ Masonry skeleton (streamed immediately)
โ โโโ error.tsx โ Error boundary for ES failures
โ
โโโ pin/
โ โโโ create/
โ โ โโโ page.tsx โ Client Component โ protected (auth not implemented), CSR only
โ โโโ [id]/
โ โโโ page.tsx โ Server Component โ minimal pin detail SSR (736w + description)
โ โโโ loading.tsx
โ
โโโ api/
โโโ search/route.ts โ GET /api/search
โโโ suggestions/route.ts โ GET /api/suggestions
โโโ pins/route.ts โ POST /api/pins
โโโ pins/upload-url/route.tsโ POST /api/pins/upload-url
# Auth boundaries
Auth is not implemented in the case study. Routes and APIs are annotated so a reviewer can see what would be gated in production:
| Route / API | Access |
|---|---|
/search, /pin/[id] |
Public |
GET /api/search, GET /api/suggestions |
Public |
/pin/create, POST /api/pins, POST /api/pins/upload-url |
Protected |
# 2. Server Component vs Client Component Split
The core decision: keep data fetching on the server, push interactivity to the client boundary.
Legend: [S] = Server Component [C] = Client Component
'use client' directive marks the boundary
# Search Page

[S] SearchPage (app/search/page.tsx)
โ Fetches ES results on server. No JS bundle cost.
โ Passes pin data as props to child CC.
โ
โโโ [C] Navbar
โ Interactive: mobile menu, user dropdown
โ
โโโ [C] SearchBar
โ Interactive: controlled input, focus state, submit handler
โ โโโ [C] SuggestionsDropdown
โ Interactive: debounce, AbortController, keyboard nav
โ
โโโ <Suspense fallback={<MasonrySkeleton />}> โ streams skeleton first
โ โโโ [C] VirtualMasonryGrid
โ SSR: renders full first page (~50 pins) with pre-calculated positions
โ Client: hydrates, then enables virtualization + infinite scroll
โ Interactive: scroll events, rAF recalc, IntersectionObserver
โ โโโ [C] PinCard[]
โ Interactive: hover state, error retry
โ โโโ <img srcset fetchpriority loading="lazy" />
โ โโโ [C] InfiniteScrollTrigger
โ IntersectionObserver sentinel โ calls loadNextPage inside grid
โ

# Pin Creation Page

[C] PinCreationPage (app/pin/create/page.tsx)
โ Entire page is Client โ highly interactive, no SEO value
โ
โโโ [C] FileDropzone
โ Drag & drop, file validation (type, 20MB limit),
โ calls POST /api/pins/upload-url โ PUT to S3 presigned URL
โ โโโ [C] UploadProgress
โ XHR with onprogress events โ shows % uploaded
โ
โโโ [C] PinForm
Controlled inputs: description
Submit: POST /api/pins with pin_id from upload step
โโโ [C] PublishButton
Disabled until upload complete
# Pin Detail Page
[S] PinDetailPage (app/pin/[id]/page.tsx)
โ Fetches pin from Postgres on server. Public, no auth.
โ Minimal scope: hero image + description text.
โ No related pins, save button, or masonry.
โ
โ User-facing: pin is Published once upload + description submit succeed.
โ Internal `processing` status (Sharp variants) is never shown to the user.
โ Hero image: 736w variant when available, original S3 URL as fallback โ
โ user always sees their image immediately after publish.
โ
โ On arrival from create flow: show "Your pin has been published" confirmation.
โ
โโโ <img src={pin.images['736w'] ?? pin.images.original} alt={pin.description} />
+ description heading/body
+ published confirmation banner (when redirected from /pin/create)
# 3. State Management
No global state manager. State is scoped to the component that owns it.
| State | Owner | Storage |
|---|---|---|
| Search query | URL (?q=) |
useSearchParams() โ URL is source of truth |
| Cursor (pagination) | VirtualMasonryGrid |
React useRef โ client-only, never in URL |
| Suggestions dropdown open | SearchBar |
useState โ local UI state |
| Suggestions list | SuggestionsDropdown |
useState โ from API response |
| Masonry column positions | VirtualMasonryGrid |
useRef (positions object, not rendered) |
| Upload progress | UploadProgress |
useState |
Pin creation pin_id |
PinCreationPage |
useState โ set after presigned URL received |
| Scroll position | VirtualMasonryGrid |
window.scrollY โ read directly, not in state |
Why URL for search query only? Shareable links, browser back/forward, SSR reads searchParams directly โ no hydration mismatch, no prop drilling. The pagination cursor is opaque and session-scoped; it stays in a client useRef, not the URL.
# 4. Data Fetching
# Search Page โ initial load (SSR)
// app/search/page.tsx โ Server Component
export default async function SearchPage({ searchParams }) {
const { q } = await searchParams; // cursor is never in the URL
const results = await fetchSearchResults(q); // always page 1 on SSR
return (
<main>
<SearchBar defaultValue={q} />
<Suspense fallback={<MasonrySkeleton />}>
<VirtualMasonryGrid initialPins={results.pins} nextCursor={results.next_cursor} query={q} />
</Suspense>
</main>
);
}
Data is fetched once on the server. The client receives hydrated pin data โ zero re-fetch on hydration.
# Suggestions โ client side
// Inside SearchBar (Client Component)
const abortRef = useRef<AbortController | null>(null);
const onQueryChange = useDebouncedCallback(async (q: string) => {
abortRef.current?.abort(); // cancel in-flight request
abortRef.current = new AbortController();
const res = await fetch(`/api/suggestions?q=${q}`, {
signal: abortRef.current.signal,
});
setSuggestions(await res.json());
}, 200);
# Infinite scroll โ CSR
// Inside VirtualMasonryGrid (Client Component)
async function loadNextPage() {
const res = await fetch(`/api/search?q=${query}&cursor=${cursorRef.current}`);
const { pins, next_cursor } = await res.json();
cursorRef.current = next_cursor;
setPins((prev) => [...prev, ...pins]); // triggers masonry recalc via rAF
}
# 5. Masonry Grid โ Implementation Detail
# Column calculation
function calcColumnCount(containerWidth: number): number {
if (containerWidth < 600) return 2;
if (containerWidth < 900) return 3;
if (containerWidth < 1200) return 4;
return 5;
}
function calcPositions(
pins: Pin[],
colCount: number,
colWidth: number,
gap: number,
) {
const colHeights = new Array(colCount).fill(0);
return pins.map((pin) => {
const col = colHeights.indexOf(Math.min(...colHeights)); // shortest column
const top = colHeights[col];
const left = col * (colWidth + gap);
const height = Math.round((pin.height / pin.width) * colWidth); // no layout shift
colHeights[col] += height + gap;
return { top, left, height, col };
});
}
Why stored dimensions matter: pin.height / pin.width is known at render time (extracted by Sharp at upload). The browser calculates pixel height before the image loads โ the DOM slot is sized correctly from the first paint. Zero CLS.
# Virtualization
function getVisibleRange(
positions: Position[],
scrollY: number,
viewportH: number,
overscan = viewportH, // 1 viewport height above and below
): [number, number] {
const top = scrollY - overscan;
const bottom = scrollY + viewportH + overscan;
let start = 0,
end = positions.length - 1;
while (
start < positions.length &&
positions[start].top + positions[start].height < top
)
start++;
while (end > start && positions[end].top > bottom) end--;
return [start, end];
}
Pins outside the visible range are replaced with a <div> of the same pre-calculated height โ scroll position is stable, no jump.
# Paint scheduling
// Scroll-driven: frame-critical
window.addEventListener(
"scroll",
() => {
requestAnimationFrame(updateVisibleRange);
},
{ passive: true },
);
// Resize-driven: non-urgent
const ro = new ResizeObserver(
debounce(() => {
requestIdleCallback(() => recalcAllPositions());
}, 150),
);
ro.observe(containerRef.current);
# 6. Image Loading Strategy
function PinCard({ pin, isAboveFold }: { pin: Pin; isAboveFold: boolean }) {
const [loaded, setLoaded] = useState(false);
const [retried, setRetried] = useState(false);
const [failed, setFailed] = useState(false);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
if (!retried) {
setTimeout(() => {
e.currentTarget.src = e.currentTarget.src; // force retry
setRetried(true);
}, 2000);
} else {
setFailed(true); // permanent failure โ show icon overlay, keep slot
}
}
return (
<div
role="listitem"
aria-label={pin.description}
style={{
position: 'absolute',
top: position.top,
left: position.left,
width: colWidth,
height: position.height,
backgroundColor: pin.dominant_color, // placeholder fills immediately
borderRadius: 16,
overflow: 'hidden',
}}
>
<img
src={pin.images['474w']}
srcSet={`${pin.images['236w']} 236w, ${pin.images['474w']} 474w`}
sizes="(max-width: 600px) 50vw, (max-width: 900px) 33vw, 25vw"
fetchPriority={isAboveFold ? 'high' : undefined}
loading={isAboveFold ? undefined : 'lazy'}
alt={pin.description}
width={pin.width}
height={pin.height}
onLoad={() => setLoaded(true)}
onError={handleError}
style={{ opacity: loaded && !failed ? 1 : 0, transition: 'opacity 0.2s' }}
/>
{failed && (
<div className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
<BrokenImageIcon className="opacity-40" /> {/* subtle overlay on dominant_color */}
</div>
)}
</div>
);
}
Above-the-fold detection: The first Math.ceil(colCount * 1.5) pins in position order are considered above the fold and receive fetchPriority="high". All others get loading="lazy".
# 7. Accessibility Implementation
// Grid container
<ul role="list" aria-label="Search results">
{/* Each pin */}
<li role="listitem" aria-label={pin.description}>
<img alt={pin.description} ... />
</li>
</ul>
{/* Infinite scroll live region */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{isLoading ? 'Loading more pins' : newPinsCount > 0 ? `${newPinsCount} new pins loaded` : ''}
</div>
{/* Suggestions dropdown */}
<div role="listbox" aria-label="Search suggestions" id="suggestions-list">
{suggestions.map((s, i) => (
<div
role="option"
id={`suggestion-${i}`}
aria-selected={highlightedIndex === i}
key={s}
>
{s}
</div>
))}
</div>
{/* Search input binding */}
<input
type="search"
aria-label="Search pins"
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-activedescendant={highlightedIndex >= 0 ? `suggestion-${highlightedIndex}` : undefined}
/>
Keyboard navigation in suggestions:
| Key | Action |
|---|---|
โ |
Move highlight down |
โ |
Move highlight up |
Enter |
Submit highlighted suggestion or current input |
Escape |
Close dropdown, return focus to input |
Tab |
Close dropdown, move focus forward |
# 8. Pin Upload Flow โ Client Sequence
1. User selects file (drop or input[type=file])
โ Validate: type โ {image/jpeg, image/png}, size โค 20MB
โ Show preview (FileReader โ object URL)
2. POST /api/pins/upload-url
โ { pin_id, upload_url, expires_in: 300 }
3. PUT upload_url (XMLHttpRequest for progress events, not fetch)
โ onprogress: update UploadProgress bar
โ onload: mark upload complete โ Publish button enabled
4. User submits description โ POST /api/pins { pin_id, description }
โ { status: 'processing' } โ internal API status; UI treats this as Published
5. Navigate to /pin/:id?published=1
โ Detail page shows published confirmation + image (original fallback until 736w exists)
โ Sharp variant generation and ES indexing continue in background โ invisible to user
User-facing vs internal status: processing in the API means Sharp is generating variants. The frontend never surfaces this. Once upload + description submit succeed, the Pin is Published from the user's perspective.
Why XHR for S3 upload instead of fetch: fetch doesn't expose upload progress via a standard API. XMLHttpRequest.upload.onprogress gives byte-level progress for the progress bar.
# 9. Error Boundaries
app/search/error.tsx โ catches ES failures, shows "Search unavailable" + retry
app/pin/[id]/error.tsx โ catches pin detail fetch failures
Within the grid:
- Image load failure โ silent retry โ dominant color placeholder (handled in
PinCard, not error boundary) - Next-page fetch failure โ
InfiniteScrollTriggershows "Couldn't load more โ try again" button (not a thrown error, just failed state)
# 10. Core Web Vitals Targets
| Metric | Target | How enforced |
|---|---|---|
| LCP (search results) | < 2.5s | fetchPriority="high" on above-fold images; Streaming SSR sends HTML shell immediately |
| CLS (masonry grid) | < 0.1 | Image dimensions stored at upload; slots pre-sized; image error never collapses slot |
| INP (scroll interaction) | < 200ms | rAF for scroll-driven recalc; passive: true scroll listener; virtualization caps DOM size |
| TTFB (search page) | < 600ms | Streaming SSR + CDN cache for hot queries |
| FID / TBT | < 300ms TBT | Masonry recalc via rAF/rIC avoids long tasks; image decode is off main thread |
# 11. Bundle Strategy
app/search/page.tsx โ Server Component (zero JS bundle cost)
VirtualMasonryGrid โ Client Component, SSR-enabled (renders initial pins on server)
Virtualization activates after hydration on scroll/resize
SuggestionsDropdown โ lazy-loaded on first focus of search bar
PinCreationPage โ entirely lazy โ users who never create don't pay for this bundle
Route-level code splitting is automatic with Next.js App Router. Component-level dynamic imports are used for suggestions (defer until focus) and the pin creation bundle (large, infrequently needed). VirtualMasonryGrid is not ssr: false โ the first page of pins ships in the streamed HTML for LCP.