Frontend Mini LLD

# Pinterest โ€” Frontend Mini LLD

Framework: Next.js 15 (App Router) ยท React 19 ยท Tailwind CSS 4
Key libraries: @tanstack/react-virtual (masonry virtualization) ยท @pinterest/shared (types + env)
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

# Dependencies (apps/web/package.json)

next ^15          App Router, RSC, next/image, API rewrites
react ^19         Server + Client Components
@tanstack/react-virtual ^3   Masonry virtualization
@pinterest/shared *          PinGridItem, PinDetail, env schemas (Zod)
tailwindcss ^4    Styling (dev)
typescript ^5.7   Type checking (dev)

# 1. Route Structure

app/
โ”œโ”€โ”€ layout.tsx                  โ† Root layout โ€” sticky header (logo + Create nav)
โ”œโ”€โ”€ page.tsx                    โ† Redirect to /search
โ”‚
โ”œโ”€โ”€ search/
โ”‚   โ”œโ”€โ”€ page.tsx                โ† Server Component โ€” blocking SSR (5 pins) + PinBrowser
โ”‚   โ””โ”€โ”€ error.tsx               โ† Error boundary for ES failures
โ”‚
โ”œโ”€โ”€ pin/
โ”‚   โ”œโ”€โ”€ create/
โ”‚   โ”‚   โ””โ”€โ”€ page.tsx            โ† Server Component wrapper โ†’ PinCreateForm (client)
โ”‚   โ””โ”€โ”€ [id]/
โ”‚       โ”œโ”€โ”€ page.tsx            โ† Server Component โ€” fetches pin, renders PinDetailView
โ”‚       โ””โ”€โ”€ not-found.tsx
โ”‚
โ””โ”€โ”€ (no app/api routes โ€” /api/* rewrites to apps/api via next.config.ts)

# 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

Search results

[S] SearchPage (app/search/page.tsx)
     โ”‚  Blocking SSR: fetches 5 above-fold pins only (ABOVE_FOLD_PINS).
     โ”‚  Estimates container width from Client Hints / UA for correct column layout on first paint.
     โ”‚  No Suspense boundary โ€” response closes immediately for fast hydration.
     โ”‚
     โ”œโ”€โ”€ [C] SearchBar
     โ”‚         Interactive: controlled input, focus state, submit handler, useTransition pending state
     โ”‚    โ””โ”€โ”€ [C] SuggestionsDropdown (lazy)
     โ”‚              Interactive: debounce, AbortController, keyboard nav
     โ”‚
     โ””โ”€โ”€ [C] PinBrowser
               Owns grid โ†” pin-detail view switch; snapshots grid state via onStateChange
               โ”‚
               โ”œโ”€โ”€ [C] VirtualMasonryGrid  (grid view)
               โ”‚         SSR: renders initial 5 pins with pre-calculated positions
               โ”‚         Client: viewport-fill fetch on mount, @tanstack/react-virtual, infinite scroll
               โ”‚    โ””โ”€โ”€ [C] PinCard[]
               โ”‚              href=/pin/:id kept; click calls onPinClick (prevents default)
               โ”‚              next/image with dominant_color placeholder layer
               โ”‚
               โ””โ”€โ”€ [C] PinDetailView  (pin view โ€” client-fetched via GET /api/pins/:id)
                         Back button, hero image, description

Search autocomplete

# Pin Creation Page

Pin creation โ€” empty state

[S] CreatePinPage (app/pin/create/page.tsx)
     โ”‚  Thin server wrapper โ€” title + layout only
     โ”‚
     โ””โ”€โ”€ [C] PinCreateForm
               Drag & drop / file input, validation (type, 20MB limit)
               POST /api/pins/upload-url โ†’ XHR PUT to S3 presigned URL (progress bar)
               Controlled description textarea
               Submit: POST /api/pins โ†’ navigate /pin/:id?published=1

# Pin Detail Page

[S] PinDetailPage (app/pin/[id]/page.tsx)
     โ”‚  Fetches pin from Postgres on server. Public, no auth.
     โ”‚  Passes pin data to shared client component.
     โ”‚
     โ””โ”€โ”€ [C] PinDetailView
               Hero: pin.images['736w'] ?? pin.images.original on dominant_color background
               Description heading
               BackButton โ€” router.back() when history exists, else /search?q=
               Published banner when ?published=1 (stripped from URL via router.replace)

In-search pin detail (from grid click): PinBrowser calls pushState('/pin/:id'), fetches GET /api/pins/:id client-side, renders the same PinDetailView. Browser back (popstate) clears the active pin and remounts VirtualMasonryGrid with saved pins/cursor from PinBrowser refs โ€” scroll position and all loaded pages are preserved. PinCard keeps href=/pin/:id so right-click โ†’ open in new tab hits the full SSR page.


# 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
Grid pins snapshot PinBrowser useRef โ€” updated via onStateChange; restored on grid remount
Active pin (from grid) PinBrowser useState โ€” id + fetched PinDetail
Suggestions dropdown open SearchBar useState โ€” local UI state
Suggestions list SuggestionsDropdown useState โ€” from API response
Masonry column positions VirtualMasonryGrid useMemo from calcPositions in lib/masonry.ts
Upload / publish phases PinCreateForm useState machine (idle โ†’ uploading โ†’ โ€ฆ)
Pin creation pin_id PinCreateForm useRef โ€” set after presigned URL received
Scroll position Browser window.scrollY โ€” preserved across pushState/back

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 (blocking SSR)

// app/search/page.tsx  โ€” Server Component
const ABOVE_FOLD_PINS = 5;

export default async function SearchPage({ searchParams }) {
  const { q = "" } = await searchParams;
  const first5 = await fetchSearch(q, undefined, ABOVE_FOLD_PINS);
  const serverWidth = resolveContainerWidth(/* Client Hints + UA */);
  return (
    <main>
      <SearchBar defaultValue={q} />
      <PinBrowser
        key={q}
        initialPins={first5.pins}
        initialCursor={first5.next_cursor}
        q={q}
        serverWidth={serverWidth}
      />
    </main>
  );
}

Five pins land in the initial HTML. VirtualMasonryGrid fetches the viewport-fill batch on mount โ€” no Suspense stream, no re-fetch of the first 5 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

Uses @tanstack/react-virtual (useWindowVirtualizer) with a custom rangeExtractor that iterates masonry slot positions โ€” the library's default sequential height model is wrong for multi-column masonry where items 0โ€“4 share top=0.

// Simplified โ€” actual code in VirtualMasonryGrid.tsx
rangeExtractor: (range) => {
  const scrollY = virtualizer.scrollOffset ?? 0;
  const vh = window.innerHeight;
  return pins
    .map((_, i) => i)
    .filter((i) => {
      const pos = positions[i];
      return pos.top + pos.height >= scrollY - OVERSCAN && pos.top <= scrollY + vh + OVERSCAN;
    });
},

Off-screen pins render as empty div placeholders at their pre-calculated positions. Container height comes from calcPositions totalHeight, not virtualizer.getTotalSize().

# Paint scheduling

// Resize-driven: debounced ResizeObserver on the grid container (~150ms)
const ro = new ResizeObserver(() => {
  debounce(() => requestAnimationFrame(() => setContainerWidth(el.offsetWidth)), 150);
});

// Scroll-driven: handled internally by @tanstack/react-virtual via useSyncExternalStore

# 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 โ†’ inline loading state in VirtualMasonryGrid (logged to console; not a thrown error)

# 10. Core Web Vitals Targets

Metric Target How enforced
LCP (search results) < 2.5s priority on first 6 PinCards; 5 pins in blocking SSR HTML
CLS (masonry grid) < 0.1 Image dimensions stored at upload; slots pre-sized
INP (scroll interaction) < 200ms @tanstack/react-virtual caps DOM size; passive scroll via virtualizer
TTFB (search page) < 600ms Blocking SSR (5 pins only) + 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)
PinBrowser                    โ†’ Client Component โ€” view switch + state preservation
VirtualMasonryGrid            โ†’ Client Component, SSR-enabled (renders initial 5 pins on server)
SuggestionsDropdown           โ†’ lazy-loaded on first focus of search bar
PinCreateForm                 โ†’ loaded via /pin/create route split

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.