[Part 2] Perf Platform — Full Project Overview · ~/feed
[post]
[Part 2] Perf Platform — Full Project Overview
·7 min read
#perf#server-side#client-side#dev
5. API Routes — the bridge between client and server
Client-side code cannot talk to your database directly (that would expose your credentials to anyone). Instead, you create API routes — server-side endpoints the browser calls.
Browser (client-side) Your Server (API route) Database───────────────────── ────────────────────── ────────fetch('/api/train/snippets') → GET handler runs → Supabase query ← JSON response ←setLyricsSnippets(data)
The API route
// apps/web/app/api/train/snippets/route.ts// This runs on the SERVER — has full access to DB, Redis, secretsexport async function GET() { const snippets = await getCached('training:snippets', 86400, getLyricsSnippets) // ^^ reads from Redis (server-only) return Response.json({ snippets }) // ^^ sends JSON to the browser}
The client call
// In TrainingView.tsx (browser)
fetch('/api/train/snippets') // browser calls your API
.then(r => r.json())
.then(d => setLyricsSnippets(d.snippets))
The browser never sees the Redis connection, Supabase URL, or auth tokens — all of that stays on the server inside the route handler.
6. useState vs server data
useState stores data in memory in the browser. It is lost on page refresh. Server data (database, Redis) persists permanently.
// Client-side — lives in the browser tab onlyconst [streak, setStreak] = useState<number | null>(null)// To get real data from the server, fetch it:useEffect(() => { fetch('/api/user/stats') .then(r => r.json()) .then(d => setStreak(d.streak)) // store server data in browser state}, [user])
Real example — SiteHeader
// apps/web/components/ui/SiteHeader.tsx'use client'export default function SiteHeader() { const [streak, setStreak] = useState<number | null>(null) // On mount (and when user changes), fetch fresh data from server useEffect(() => { if (!user) { setStreak(null); return } const controller = new AbortController() fetch('/api/user/stats', { signal: controller.signal }) .then(r => r.ok ? r.json() : null) .then(d => { if (d) setStreak(d.streak ?? 0) }) .catch(() => {}) return () => controller.abort() // cancel if component unmounts }, [user]) return <header>🔥 {streak ?? '…'}</header>}
The null initial state means "not loaded yet" — the header shows a skeleton while waiting.
7. The rendering timeline in detail
Here is what the browser actually does, mapped to code:
Time 0ms → Browser sends request to /trainTime 50ms → Server renders HTML (TrainPage Server Component) Sends: <h1>Train</h1> + empty div placeholderTime 51ms → Browser paints: heading visible, no interactivity
Time 200ms → JS bundle arrives (React + TrainingView code)
Time 250ms → React hydrates — attaches event listeners
Time 260ms → useEffect fires:
generateBuffer('common') runs → word buffer created
TrainingView shows typing area with words
↑ Page is fully usable hereTime X → User clicks "Lyrics" tabTime X+5ms → useEffect(mode='lyrics') fires fetch('/api/train/snippets') sentTime X+30ms → Response arrives (Redis hit ~20ms) setLyricsSnippets(data) regenerate() runs → lyrics buffer created Typing area shows song lyrics
Compare to the old flow:
Time 0ms → Browser sends request to /trainTime 0–8000ms → Server waits for lyrics fetch (external APIs, cold cache)Time 8050ms → Server sends HTMLTime 8250ms → JS hydratesTime 8260ms → Page usable
8. Server-side rendering (SSR) vs Static generation (SSG) vs Client-side rendering (CSR)
These are three different strategies for delivering pages:
SSR — Server-Side Rendering
Page HTML is generated on each request by the server.
// Happens every time someone visits /play/[songId]export default async function PlayPage({ params }) { const lyrics = await prefetchLyrics(...) // runs per request return <GameView initialLyrics={lyrics} />}
Good for: pages with user-specific or frequently changing data.
Bad for: pages where every user sees the same thing (wasted work).
SSG — Static Site Generation
Page HTML is generated once at build time and served to everyone.
// Would be generated once when you run `next build`export default function AboutPage() { return <p>TypeLyrics is a typing game.</p>}
Good for: marketing pages, docs, any content that doesn't change per user.
Bad for: personalized data or anything that changes often.
CSR — Client-Side Rendering
Page is a blank shell; JavaScript builds the UI in the browser.
// Server sends: <div id="root"></div>// Browser JS then renders everything
Old-school React SPAs worked this way. Next.js avoids this for initial loads (bad for SEO and first paint).
What TypeLyrics uses
Route
Strategy
Why
/train
SSR (fast, no data)
Nonce in root layout forces SSR
/play/[songId]
SSR
Prefetches lyrics per request
/browse
SSR
Dynamic content
Typing area
CSR (client component)
Needs useState, real-time input
TrainingView
CSR
Math.random, user interaction
9. Hydration — and why it sometimes causes bugs
Hydration is the process where React takes server-rendered HTML and "wakes it up" by attaching JavaScript event handlers to it.
The problem: if the server renders something different from what the client would render, React throws a hydration mismatch error.
Classic example — Math.random()
// ❌ This breaks hydrationfunction TrainingView() { // Server renders: "the cat sat on" // Client renders: "run fast away from" (different random words!) const [buffer] = useState(generateBuffer('common')) return <div>{buffer}</div>}
React panics because the HTML it received from the server doesn't match what it's trying to render on the client.
The fix — generate client-side only
// ✅ This worksfunction TrainingView() { const [buffer, setBuffer] = useState('') // empty on server useEffect(() => { // Only runs in browser — server never runs this setBuffer(generateBuffer('common')) }, []) return <div>{buffer || ' '.repeat(200)}</div> // ^ placeholder while JS loads}
The server sends an empty buffer. The client fills it in. No mismatch.
This is exactly why TrainingView uses useEffect to generate the word buffer rather than doing it directly in useState.
10. 'use client' boundary — what it means for the bundle
When you put 'use client' at the top of a file, everything that file imports is also included in the client JS bundle — even if those imports are only used at the top level.
'use client'import { motion } from 'framer-motion' // 80kb added to browser bundleimport { useState } from 'react'
This is why we lazy-load things that aren't needed immediately:
// apps/web/app/layout.tsx// ❌ Would add framer-motion to the initial bundle for every page// import ThemeSwitcher from '@/components/ui/theme'// ✅ Only loads when needed, after initial paintconst ThemeSwitcher = dynamic(() => import('@/components/ui/theme'), { ssr: false })
ssr: false means: don't even try to render this on the server. It's a floating overlay that needs window — server rendering it would crash.
11. Common client-side patterns in this codebase
Pattern: AbortController — cancel fetch on cleanup
// apps/web/components/ui/SiteHeader.tsxuseEffect(() => { if (!user) return const controller = new AbortController() fetch('/api/user/stats', { signal: controller.signal }) .then(...) .catch(() => {}) // AbortError is expected — don't log it // Cleanup: if user logs out before fetch finishes, cancel it return () => controller.abort()}, [user])
Without this, if the user logs out while the fetch is in-flight, React would try to call setStreak on an unmounted component (memory leak / warning).
Pattern: ref for DOM mutations without re-renders
// apps/web/components/ui/NavigationTyping.tsxconst typedRef = useRef<HTMLSpanElement>(null)function typeChar(idx: number) { // Direct DOM write — zero React re-renders typedRef.current!.textContent = text.slice(0, idx)}
useRef gives you a pointer to a DOM element. Writing to .textContent directly is faster than calling setState because React doesn't need to re-render — the browser just updates that one text node.
Pattern: custom event for cross-component communication