# Pinterest โ Frontend High-Level Design (HLD)
Framework: Next.js App Router (React Server Components + Suspense streaming)
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
# 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: Streaming SSR on initial load, CSR for 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=... |
Streaming SSR (page 1) + CSR (scroll) | SEO, shareable URLs, LCP via streamed HTML + above-fold images |
/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 page-1 results; reads ?q= from URL
โโโ SearchBar [Client] โ input, submit, URL sync
โ โโโ SuggestionsDropdown [Client] โ debounced fetch, keyboard nav
โโโ VirtualMasonryGrid [Client] โ SSR initial pins; client virtualization + infinite scroll
โโโ PinCard [Client] โ image loading, error retry
โโโ InfiniteScrollTrigger [Client] โ IntersectionObserver sentinel
Pin creation
โโโ PinCreationPage [Client] โ entire page
โโโ FileDropzone [Client] โ validate, presigned URL, XHR upload to S3
โโโ UploadProgress [Client]
โโโ PinForm [Client] โ description, publish
Pin detail
โโโ PinDetailPage [Server] โ hero image + description + published banner
# Server vs client boundary
| Concern | Server | Client |
|---|---|---|
| Initial search results | โ | |
| Pin detail fetch | โ | |
| 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 page only | Scroll, resize, infinite scroll |
| 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 |
PinCreationPage |
useState โ set after presigned URL issued |
# 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 (page 1)
โ scroll โ GET /api/search?cursor=โฆ โ append pins
Create
Select file โ POST /api/pins/upload-url โ PUT S3 (XHR)
โ POST /api/pins โ navigate /pin/[id]?published=1
# Optimizations & Deep Dive
# Streaming SSR (search initial load)
- Server reads
qfromsearchParams(always page 1). - Server fetches
GET /api/search(Redis โ ES). - HTML shell streams immediately;
<Suspense>streams skeleton then populated grid. - Client hydrates with pin data in the DOM โ no re-fetch on hydration.
# Masonry grid
- Layout: JS absolute positions from stored
width/height; shortest-column placement; zero CLS. - Placeholder:
dominant_colorslot; image crossfades on load. - Virtualization: Viewport ยฑ 1 overscan; off-screen height placeholders preserve scroll.
- Scheduling:
rAF(scroll),rIC+ debouncedResizeObserver~150 ms (resize). - SSR: Full first page (~50 pins) on server; virtualization after hydration.
# 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 | Streaming SSR + fetchPriority on above-fold imgs |
| 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 | Suspense skeleton until stream completes |
| 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) | Streaming SSR, server data fetching |
| 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).