What This Post Covers
I'm organizing what I learned while doing two big things in an app. I leave out specific products and numbers and cover only general concepts (Next App Router rendering, react-query cache, HTTP conditional requests). The theme running through both tasks was "getting the UX you want while keeping the static cache (ISR)."
What I Worked On Today
| Task | What I wanted | What I did | Result |
|---|---|---|---|
| Boot splash | Show a logo from the first screen but hide it from bots | Inline blocking script injection instead of SSR/UA gate | Keep static render/ISR + hide from bots |
| Offline-first | Read immediacy + minimal traffic + my writes reflect instantly | Combine persist, optimistic writes, invalidate, durable queue, ETag | All three requirements met at once |
1. Reveal Before First Paint + Hide From Bots = Inline Script
The situation. On first app entry, I wanted to show a logo splash from the first paint like a native app. But the splash had to stay out of the crawler index.
Core concept. Three alternatives were each blocked. SSR markup rides in the first HTML and leaks into the bot index. A UA gate (skip if the request header says it's a bot) forces the static generation/ISR of sub-routes into dynamic the moment you read headers() in a shared layout, invalidating the entire static cache. ISR (Incremental Static Regeneration) pre-bakes the page statically and refreshes it only periodically, but once it becomes dynamic it falls back to a server render on every request and gets slow. A client splash that appears only after mount has empty initial state on the server, creating a first-paint gap. The answer is to put an inline blocking script at the top of <body> and draw the splash node directly — it doesn't remain in the bot HTML that doesn't run JS, and modern React tolerantly skips the unexpected node inserted at the top of body during hydration, so there's no mismatch.
What I did. I dropped SSR and switched to inline script injection. After hydration the client fades the splash out with opacity. When putting a string into the inline script, I unicode-escaped < and set it via textContent to prevent early </script> termination.
Lesson. For reveal before first paint + hiding from bots, an inline blocking script is the answer. Before using headers()/cookies() in a shared layout, check whether sub-routes are ISR.
→ Reveal Before First Paint + Hide From Bots — Inline Blocking Script
2. Offline-First — Instant Reflection Comes From invalidate, Not staleTime
The situation. I had to make every page of the app offline-first while satisfying three requirements at once: (1) read immediacy (shows up right away even on refresh), (2) minimal backend traffic, (3) my writes reflect instantly. Done naively, the three requirements conflict — lowering the cache freshness time for immediacy makes traffic explode.
Core concept. Two insights were key.
First, "my writes reflect instantly" comes not from lowering staleTime but from invalidateQueries after the write. staleTime decides how many seconds to trust the data as fresh and hold off a background refetch, while invalidateQueries ignores that staleTime and immediately refetches active queries. So keep staleTime as a freshness SLA (e.g., 10 seconds) to suppress traffic, and when a write succeeds, scope-invalidate only the related queries to force a refetch just at that moment. Layer optimistic updates on top to change the screen first and roll back on failure, and you secure perceived immediacy too.
Second, a reload-durable offline mutation queue must survive refreshes. For a write made offline (likes, comments) to be stacked locally and resume after reconnect/refresh, persist must save that mutation key, setMutationDefaults must provide a resume path that runs without a mounted component, and the values needed for reconstruction (target id, etc.) must ride in variables. Mutation callbacks can't read the mutationKey, so if even one of these three is missing, you get a stuck queue that gets restored but can't be drained.
What I did. I attached, step by step: persist the cache to a local DB (IndexedDB) (keep staleTime, focus refetch off), optimistic writes + onError rollback, scope invalidate after writes, a setMutationDefaults reload-durable queue, ETag/304 on read routes, and per-group LRU eviction of persist snapshots. I skipped the server-side payload cache, judging it over-investment: details are already covered by ISR, and lists have high query-string cardinality so cache hits don't happen. I also confirmed the limit that ETag saves only bandwidth, not server compute.
Lesson. Instant reflection via invalidate, freshness SLA via staleTime — different roles. An offline queue needs all three of persist + setMutationDefaults + variables. Before optimizing persist and the server cache, first look at the target page's ISR status and query-string cardinality.
→ invalidateQueries vs staleTime — My Writes Reflect Instantly