TanStack Start + React Server Components, with a local CDN simulator (reverse proxy) that mimics CDN caching (Cache-Control, Vary, Cache-Tag) and a news-site-style home page (src/routes/index.tsx) for recordings.
Three things you can show on camera:
- Page cache vs PPC fragment. The hero + latest grid are SSR'd in the document HTML; the trending sidebar is a separate
GET /_serverFn/<id>request with its ownCache-ControlandCache-Tag, so it can be invalidated independently of the page. - Real RSC fragments. The trending server function returns a
renderServerComponent(...)renderable, so what flows over the wire is a React Flight stream, not JSON. The client embeds the rendered tree directly. - Pay-for-what-you-render JS. Each trending tile picks
BasicStory(server-only, no JS) orInteractiveStory('use client', with a button). The InteractiveStory chunk is only fetched by the browser when the Flight stream contains at least one interactive tile.
pnpm installLocal development (HMR, no proxy):
pnpm devApp: http://localhost:3000
Production-style demo (stable caching — use this when filming cache hits/misses):
pnpm demoThis runs, in order: pnpm build → Vite preview on :3000 → cdn-simulator.mjs on :8080. Open the app through the simulator:
- App: http://localhost:8080/ (PPC home)
- Cache visualizer: http://localhost:8080/__cache/view
The home route is server-rendered: hero and latest news come from the route loader. The trending block is a separate RSC fragment — TrendingClient calls getTrending after hydration (GET /_serverFn/… in DevTools), and the response is a Flight stream from renderServerComponent(<Trending …/>). Its own Cache-Control + Cache-Tag make it independently cacheable / purgable. Purge actions in the newsroom panel call the simulator on port 8080 (or same-origin when you are already on :8080). Stop both servers with Ctrl+C.
Manual (same as pnpm demo, but two terminals):
pnpm build
pnpm preview # :3000
pnpm cdn # or pnpm proxy — :8080 in another terminal| Command | What it does |
|---|---|
pnpm dev |
Vite dev server on port 3000 |
pnpm build |
Production build (RSC, client, SSR, Nitro) |
pnpm preview |
Serves the built app on port 3000 |
pnpm cdn / pnpm proxy |
Starts cdn-simulator.mjs (port 8080 → origin 3000) |
pnpm demo |
scripts/ppc-demo.mjs: build + preview + cdn-simulator |
pnpm add-basic-story "…" |
scripts/add-story.mjs with STORY_TYPE=basic: POST /publish-trending adds a basic story (rendered server-only by BasicStory — no JS for that tile). Then also purges the CDN tag trending on :8080 so the next GET /_serverFn/<id> is a MISS. Set SKIP_EDGE_PURGE=1 to skip the purge — handy for showing stale-edge → manual-purge on camera. ORIGIN / PURGE_ORIGIN pin URLs. |
pnpm add-interactive-story "…" |
Same as above with STORY_TYPE=interactive: adds an interactive story (rendered by InteractiveStory, which is 'use client' and has a More info… button). The browser lazy-loads the InteractiveStory chunk via the RSC Flight stream only when at least one story is interactive. SKIP_EDGE_PURGE=1 is supported here too. |
pnpm test |
Vitest (no tests in the repo yet) |
cdn-simulator.mjs— dependency-freenode:httpCDN simulator (reverse proxy). Simulates a shared edge CDN: honorsno-store/private/s-maxage=0, preferss-maxageandmax-agewhen they imply a positive TTL, and when the origin is silent (or only sendsmax-age=0on public HTML) applies default edge TTLs (e.g. long for/assets/…fingerprints, 300s for HTML). Purge still works with origin + synthetic tags (edge-cdn,html,static, …). Not production-grade./(home) — page-cached regions (hero + latest) with labeled borders, an RSC fragment for trending, and a newsroom control panel for publish + CDN purge. Route:src/routes/index.tsx. Publish API:src/routes/__newsroom/publish-trending.ts(HTTP pathPOST /publish-trending, accepts{ headline?, type? }).- Trending RSC pipeline —
getTrendingis aGETserver function that callsrenderServerComponent(<Trending …/>), setsCache-Control: max-age=0, s-maxage=30andCache-Tag: trending, homepage, and returns the renderable so the framework streams a Flight payload.TrendingClientcalls it fromuseEffectand embeds the result with{Trending}— nocreateFromReadableStreamon the client, no separate decoder chunk. - Vite —
vite.config.tsenables TanStack Start with@vitejs/plugin-rscandtanstackStart({ rsc: { enabled: true } }).
Each entry in the trending list has a type field:
basic→ renders withBasicStory. No'use client', no JS shipped to the browser for that tile.interactive→ renders withInteractiveStory.'use client', with aMore info…button. Its chunk is only fetched by the browser when the Flight stream contains at least one interactive tile.
Seed stories are basic; pnpm add-interactive-story "…" produces interactive stories. With seed-only data, no trending-*.js chunk is fetched. After publishing one interactive story, the chunk shows up in DevTools → Network alongside the next GET /_serverFn/<id> request — and stays out of the bundle for pages that don't need it.
getTrendingsetsCache-Tag: trending, homepage.pnpm add-{basic,interactive}-story "…"callsPOST /publish-trending(mutates the in-memory list) and thenPOST /__cache/purgewith{ tag: 'trending' }against the simulator. SetSKIP_EDGE_PURGE=1to skip the purge.- The in-app Newsroom panel exposes the same actions: a publish button (always
interactive) and Purge "trending" tag / Purge all against the simulator. - After a purge, the next
GET /_serverFn/<id>is aMISS(returns the new list). Subsequent reloads HIT for ≤ 30s (s-maxage), then expire and MISS again. That's the whole demo.
src/routes/ File-based routes (__root, index, __newsroom/publish-trending)
src/components/ppc/ News UI + region frames
Trending.tsx Picks BasicStory or InteractiveStory per `story.type`
BasicStory.tsx Server-only tile (no JS in the client bundle)
InteractiveStory.tsx 'use client' tile with the `More info…` button
TrendingClient.tsx Calls getTrending() and embeds the renderable
PageCacheRegion / PPCFragmentRegion / ControlRegion / CacheLegend / Newsroom
src/data/ Static article seed (HERO_ARTICLE, LATEST_ARTICLES)
src/server/trending.tsx Trending state + `getTrending` (renderServerComponent + Cache-Tag)
src/styles/ppc.css PPC border variables (imported from styles.css)
cdn-simulator.mjs Local CDN simulator (port 8080)
scripts/ppc-demo.mjs One-command build + preview + cdn-simulator
scripts/add-story.mjs POSTs /publish-trending with STORY_TYPE, then purges the edge
Generated: src/routeTree.gen.ts (do not edit by hand).
Tailwind CSS v4 via @tailwindcss/vite. Global styles: src/styles.css, including src/styles/ppc.css for PPC demo frames.
Vitest is configured; there are no *.test/*.spec files yet. pnpm test will exit with “no test files” until you add some.