# 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
processingstatus (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 |

# 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)
- Server reads
qfromsearchParams. - Server fetches
GET /api/searchwithlimit=5only โ response closes immediately (no Suspense boundary). - Server estimates container width from
Sec-CH-Viewport-Width(Client Hints) or UA fallback. - Client hydrates
PinBrowserโVirtualMasonryGridwith 5 pins in the DOM. - 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_colorslot behindnext/image. - Virtualization:
@tanstack/react-virtualwith a customrangeExtractorthat uses masonry slot positions (not sequential item heights). - Scheduling:
ResizeObserverdebounced ~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:
- Wait 2s and retry
srconce โ catches transient mobile network drops and brief S3 hiccups silently. - If retry fails: keep the pre-calculated masonry slot, display dominant color background + subtle broken-image icon overlay.
- 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)
VirtualMasonryGridSSR-enabled for LCPSuggestionsDropdowndynamic import on focus/pin/createlazy 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).