Frontend HLD

# Pinterest โ€” Frontend High-Level Design (HLD)

Framework: Next.js 15 App Router (React 19 Server + Client Components)
Structure: RADIO โ€” Requirements ยท Architecture ยท Data model ยท Interface (API) ยท Optimizations & deep dive
Companion doc: FRONTEND_DESIGN.md โ€” mini LLD with component trees, code samples, and implementation detail
System context: DESIGN.md โ€” full-stack design
Domain glossary: CONTEXT.md

# Tech stack (apps/web)

Dependency Version Role
Next.js 15 App Router, RSC, blocking SSR, next/image, /api/* rewrites to apps/api
React 19 UI components, hooks, Server/Client Component boundaries
@tanstack/react-virtual 3 Masonry grid virtualization (useWindowVirtualizer + custom rangeExtractor)
Tailwind CSS 4 Utility-first styling (@tailwindcss/postcss)
TypeScript 5.7 Static typing across the monorepo
@pinterest/shared workspace Shared types (PinGridItem, PinDetail, โ€ฆ) and Zod env schemas

Intentionally not used: global state library (Redux, Zustand), data-fetching framework (React Query, SWR), UI component library, CSS-in-JS. State is component-local; data fetching is native fetch (server) and XMLHttpRequest (S3 upload progress).


# Requirements

# Functional

ID Requirement Surfaces
FR-1 Search pins by text query; results in a masonry grid /search
FR-2 Autocomplete suggestions while typing (before submit) /search
FR-3 Infinite scroll through search results /search
FR-4 Create a pin: upload image (JPEG/PNG โ‰ค 20 MB) + description /pin/create
FR-5 View a published pin after creation /pin/[id]
FR-6 Shareable search URLs (?q=) /search

# Non-functional

ID Requirement Target
NFR-1 Largest Contentful Paint (search) < 2.5 s
NFR-2 Cumulative Layout Shift (masonry) < 0.1
NFR-3 Interaction to Next Paint (scroll) < 200 ms
NFR-4 Time to First Byte (search) < 600 ms
NFR-5 Accessible search grid and suggestions WCAG-aligned patterns (see Mini LLD ยง7)
NFR-6 No global state manager Component-local state only

# Out of scope

  • Auth implementation (boundaries documented only)
  • Save-to-board UI and API
  • Pin detail related pins / masonry
  • Canvas-based pin editor (future extension โ€” see CONTEXT.md)
  • Video upload

# Constraints (locked)

  • Search: Blocking SSR (5 pins) on initial load, CSR for viewport fill + infinite scroll (ADR-0001)
  • Pin creation: CSR only โ€” no SEO value, highly interactive
  • Search indexing: eventual consistency via CDC โ€” newly published pins appear in search after backend processing (not a frontend concern)
  • Published is user-facing; internal processing status (Sharp variants) is never shown in the UI

# Architecture / High-Level Design

# Rendering strategy by route

Route Strategy Rationale
/search?q=... Blocking SSR (5 pins) + CSR (viewport fill + scroll) SEO, fast TTFB, shareable URLs, early hydration
/pin/create CSR Interactive upload flow; no crawl value
/pin/[id] SSR Shareable pin URLs; minimal hero + description

Frontend architecture diagram

# Core principle

Keep data fetching on the server; push interactivity to the client boundary.

Server Components own initial data loads (search results, pin detail). Client Components own browser APIs and user interaction (scroll, upload progress, suggestions debounce, masonry virtualization after hydration).

# Component layering

Search page
โ”œโ”€โ”€ SearchPage [Server]           โ€” fetches 5 above-fold pins; reads ?q= from URL; estimates container width from Client Hints / UA
โ”œโ”€โ”€ SearchBar [Client]              โ€” input, submit, URL sync
โ”‚   โ””โ”€โ”€ SuggestionsDropdown [Client]  โ€” lazy-loaded; debounced fetch, keyboard nav
โ””โ”€โ”€ PinBrowser [Client]             โ€” owns grid โ†” pin-detail view switch; preserves loaded pins across remounts
    โ”œโ”€โ”€ VirtualMasonryGrid [Client] โ€” SSR initial 5 pins; client viewport fill + virtualization + infinite scroll
    โ”‚   โ””โ”€โ”€ PinCard [Client]        โ€” next/image, click intercept via onPinClick
    โ””โ”€โ”€ PinDetailView [Client]      โ€” shown when a pin is opened from the grid (client-fetched)

Pin creation
โ””โ”€โ”€ CreatePinPage [Server]          โ€” thin wrapper
    โ””โ”€โ”€ PinCreateForm [Client]      โ€” file pick/drop, XHR upload to S3, description, publish

Pin detail
โ””โ”€โ”€ PinDetailPage [Server]          โ€” fetches pin on server
    โ””โ”€โ”€ PinDetailView [Client]      โ€” hero image + description + back button + published banner

# Server vs client boundary

Concern Server Client
Initial search results โœ“ (5 pins)
Pin detail fetch โœ“ (/pin/[id] route) โœ“ (from grid via PinBrowser)
Search query source of truth URL (?q=) โ€” read on both sides
Pagination cursor useRef in grid (never in URL)
Suggestions Debounced fetch
Masonry layout + virtualization SSR first 5 pins only Scroll, resize, viewport fill, infinite scroll
In-search pin detail pushState + client fetch; popstate restores grid
Image upload XHR to S3 presigned URL
Global state (Redux, Zustand) โ€” Not used

# Auth boundaries (annotated, not implemented)

Surface Access
/search, /pin/[id], GET /api/search, GET /api/suggestions Public
/pin/create, POST /api/pins, POST /api/pins/upload-url Protected (production)

# State ownership (no global store)

State Owner Storage
Search query URL ?q= โ€” shareable, SSR-readable
Pagination cursor VirtualMasonryGrid useRef โ€” opaque, client-only
Suggestions list SuggestionsDropdown useState โ€” ephemeral per keystroke
Masonry positions VirtualMasonryGrid useRef โ€” derived, not rendered
Upload progress UploadProgress useState
Pin creation id PinCreateForm useRef โ€” set after presigned URL issued
Grid pins + cursor PinBrowser useRef snapshots from onStateChange โ€” restored when grid remounts after closing a pin

# Data Model

Frontend-facing shapes โ€” source of truth remains Postgres / Elasticsearch on the backend (see DESIGN.md ยง2).

# Pin (search grid card)

type PinGridItem = {
  id: string;
  description: string;
  width: number;           // original px โ€” masonry height calc
  height: number;
  dominant_color: string;  // hex, e.g. "#a3b4c5"
  images: {
    '236w': string;        // CDN URL
    '474w': string;
  };
};

# Pin (detail page)

type PinDetail = {
  id: string;
  description: string;
  dominant_color: string;
  images: {
    '736w'?: string;
    original: string;      // fallback until variants exist
  };
};

# Search results page

type SearchResults = {
  pins: PinGridItem[];
  next_cursor: string | null;
};

# Suggestions

type Suggestions = string[];   // completion terms from ES suggester

# Upload / publish

type UploadUrlResponse = {
  pin_id: string;
  upload_url: string;
  expires_in: number;        // seconds
};

type CreatePinResponse = {
  pin_id: string;
  status: 'processing';      // internal โ€” UI treats as Published
};

# URL parameters

Param Route Purpose
q /search Search query โ€” source of truth
published /pin/[id] 1 โ†’ show published banner

Cursor is not in the URL โ€” opaque search_after token held in client useRef.


# Interface Design (API)

# Routes

Path Component type Primary data source
/search Server + Client children GET /api/search (SSR, page 1)
/pin/create Client POST /api/pins/upload-url, POST /api/pins, S3 PUT
/pin/[id] Server GET /api/pins/:id

# API surface (frontend consumer)

Endpoint When Auth (production)
GET /api/search?q=&cursor= SSR page 1 (no cursor); CSR scroll (with cursor) Public
GET /api/suggestions?q= Client, debounced on input Public
POST /api/pins/upload-url Before S3 upload Protected
POST /api/pins After upload + description Protected
GET /api/pins/:id Pin detail SSR Public
S3 presigned PUT Direct from browser (XHR) โ€”

# User flows

Search

User types โ†’ GET /api/suggestions (debounced)
  โ†’ submit โ†’ /search?q=
  โ†’ SSR GET /api/search (5 pins, blocking)
  โ†’ client mount โ†’ GET /api/search (viewport fill)
  โ†’ scroll โ†’ GET /api/search?cursor=โ€ฆ โ†’ append pins
  โ†’ click pin โ†’ pushState /pin/:id โ†’ GET /api/pins/:id โ†’ PinDetailView overlay
  โ†’ browser back โ†’ popstate โ†’ grid remounts with saved pins + scroll position
  โ†’ right-click / open in new tab โ†’ full /pin/[id] page (SSR)

Create

Select file โ†’ POST /api/pins/upload-url โ†’ PUT S3 (XHR)
  โ†’ POST /api/pins โ†’ navigate /pin/[id]?published=1

# Optimizations & Deep Dive

# Search initial load (two-phase blocking SSR)

  1. Server reads q from searchParams.
  2. Server fetches GET /api/search with limit=5 only โ€” response closes immediately (no Suspense boundary).
  3. Server estimates container width from Sec-CH-Viewport-Width (Client Hints) or UA fallback.
  4. Client hydrates PinBrowser โ†’ VirtualMasonryGrid with 5 pins in the DOM.
  5. On mount, grid fetches the next batch to fill the viewport, then infinite scroll via IntersectionObserver.

# Masonry grid

  • Layout: JS absolute positions from stored width / height; shortest-column placement; zero CLS.
  • Placeholder: dominant_color slot behind next/image.
  • Virtualization: @tanstack/react-virtual with a custom rangeExtractor that uses masonry slot positions (not sequential item heights).
  • Scheduling: ResizeObserver debounced ~150 ms for container width; scroll handled by the virtualizer.
  • SSR: First 5 pins in blocking HTML; virtualization activates after hydration when the virtualizer reports items.

# Image loading

Context Strategy
Above-the-fold fetchPriority="high" โ€” first ~1.5 rows
Below-the-fold loading="lazy"
Grid srcset 236w + 474w
Detail hero 736w or original fallback
Load failure 2 s retry โ†’ dominant color + broken-image icon; slot preserved

# Image error handling

On onerror for a pin image:

  1. Wait 2s and retry src once โ€” catches transient mobile network drops and brief S3 hiccups silently.
  2. If retry fails: keep the pre-calculated masonry slot, display dominant color background + subtle broken-image icon overlay.
  3. Never collapse the slot โ€” collapsing requires column recalculation and causes layout shift, violating CLS.

# Caching

Two layers, complementary:

  • CDN โ€” caches the SSR HTML of the first page of results for hot queries (e.g. q=keyboards). TTL-based invalidation. Handles thundering herd on trending terms.
  • Redis โ€” caches API-layer search results (JSON) for all cursor pages and suggestion requests. Cache key: q={query}&cursor={cursor}. 60s TTL โ€” acceptable given CDC lag already introduces eventual consistency.

# Suggestions

200 ms debounce ยท AbortController per keystroke ยท lazy-loaded on first focus ยท listbox / keyboard nav (see Mini LLD ยง7).

# Pin creation UX

Published = upload + description submit succeed โ€” navigate immediately. Internal processing / Sharp / CDC never surfaced. XHR (not fetch) for S3 upload progress.

# Bundle strategy

  • App Router route splitting (automatic)
  • VirtualMasonryGrid SSR-enabled for LCP
  • SuggestionsDropdown dynamic import on focus
  • /pin/create lazy route

# Core Web Vitals

Metric Target Lever
LCP < 2.5 s Blocking SSR (5 pins) + priority on first 6 cards
CLS < 0.1 Pre-sized slots from stored dimensions
INP < 200 ms rAF scroll recalc + virtualization
TTFB < 600 ms Streamed shell + CDN cache on hot queries
TBT < 300 ms rAF / rIC masonry scheduling

# Observability

Signal Indicates
LCP First-row image paint / SSR stream health
CLS Masonry slot stability
INP Scroll + virtualization performance
TTFB CDN + API latency
Upload funnel Drop-off: presigned URL โ†’ S3 โ†’ publish

# Failure modes

Scenario UX
ES down Search error boundary + retry
Slow ES Empty-state message on search page
Image CDN hiccup Silent retry โ†’ color placeholder
Scroll fetch fails Inline retry; existing pins remain
S3 upload fails Progress error; publish disabled
New pin not in search Expected (CDC lag); detail page works after publish

# Accessibility

Masonry grid:

  • Grid container: role="list"
  • Each pin card: role="listitem", aria-label="{description}"
  • Each <img>: alt="{description}"
  • Infinite scroll trigger: aria-live="polite" region announcing when new pins load
  • Keyboard navigation follows DOM (insertion) order โ€” tab order is not overridden to match visual column order

Suggestions: listbox / option dropdown ยท aria-activedescendant for keyboard highlight ยท โ†‘/โ†“ navigate, Enter submits, Escape closes.

Pin creation form: standard label/input associations, no custom a11y needed.

# Decision log

Decision Choice Rationale
Framework Next.js App Router (RSC) Server data fetching; blocking SSR for search
Search cursor Client useRef, not URL Opaque token; q stays shareable
Masonry SSR Full first page on server LCP; virtualization post-hydration
Upload transport XHR to S3 Upload progress events
Auth Annotated only Case study scope
Publish UX Immediate success Internal processing invisible

For calcPositions, TypeScript samples, ARIA markup, and route file tree โ€” see FRONTEND_DESIGN.md (Mini LLD).