Frontend Mini LLD

# 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

Search results

[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
     โ”‚

Search autocomplete

# Pin Creation Page

Pin creation โ€” empty state

[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 โ†’ InfiniteScrollTrigger shows "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.