🔑 Key Insights
✦ AI·GENThis post documents how the author rebuilt the blog rendering on koimsurai.com, turning a 100% CSR React SPA into an SSG/ISR site. It walks through the framework selection — why a hybrid SSG suits a blog best, why Next.js was evaluated but not chosen, and how React Router 7 nearly won until the desire for ISR flipped the decision to TanStack Start — and then the implementation: article bodies baked into the HTML, heavy interactive components kept on the client, and MDX compiled on the server before being eval-run on the client with runSync. It also records two expensive detours: a prerender that produced 111 files, none of which were ever served, pushing the site to pure ISR with a Rust middleware for publish-time revalidation that nearly purged the entire cache on every page view; and a night of entry-render debugging covering an unstyled flash, a double render, and a scroll held hostage by scroll-behavior: smooth. Along the way it explains SSR, SSG, CSR, ISR and hydration for readers new to the terms, and closes with the technical debts still outstanding.
Every blog has to answer the same question: how does a post get from the database onto the reader's screen? For the past two years my site went with the laziest answer available: . This post is the story of rewriting that answer three times: first moving to SSG, then getting slapped in the face by my own prerender and switching to ISR, and finally spending an entire night wrestling with render timing just so that opening a post wouldn't flash.
Let me get a few terms straight first, because the whole post revolves around them:
- SSR (): the server renders the HTML fresh on every request and sends it out, so the first paint already has content.
- SSG (): pages are pre-rendered into static HTML files at build time and simply served as files afterwards.
- CSR (): the browser gets an empty shell and paints it itself — which is exactly what I had.
- ISR (): in between the two — serve the pre-generated static HTML first (fast), and regenerate it in the background on a schedule or on demand (fresh).
I've tried to present the whole process faithfully, including the places where I judged wrong and the places where I had to stop myself and think again.
Background: a blog invisible to crawlers, and a me who wanted everything in Rust#
Before the rewrite, this site was a perfectly ordinary React 19 SPA: BrowserRouter + <Routes>, native fetch plus a hand-rolled cache, zero SSR, one hundred percent CSR. A real person opening the homepage got an empty root node plus a bundle of JS, with all the content grown by the browser; crawlers could see meta tags only because the serve layer used user-agent detection to spot bots and stuffed <title>, OG and JSON-LD into the static HTML. In other words: the body of a post had never once been rendered by the server. For a blog where the content is the whole product, that architecture is backwards.
At the same time I had something else on my mind: I wanted to move the entire backend to Rust. That one needs some setup for readers without the context, because it actually has nothing to do with making the blog faster.
Incidentally, the SEO symptom I cared most about back then was that Google kept autocorrecting "Koimsurai" into "Katsurai" (a tonkatsu place in Kyoto). Digging into it, that turned out to be a brand entity recognition problem, and SSG can't fix it. But "both humans and crawlers should get body content that was actually rendered" was still worth doing.
Picking a stack: SSR / SSG / CSR, and why not Next#
Laid out flat there were three roads: keep the status quo (all CSR plus the bot meta shim), a hybrid SSG (prerender the article pages, leave the dashboards on CSR), or SSR for the whole site. For a blog the answer leans surprisingly toward SSG — and it actually fits my definition of "all Rust" better:
- what SSG produces is just a pile of static HTML that the Rust backend can serve directly, meaning zero rendering Node in production;
- full-site SSR would add a long-lived Node render process in prod, which dilutes "all Rust" instead;
- and my site naturally splits into two kinds of content: semi-static posts and static pages (which need SEO) and live dashboards (now / watching / music — constantly changing, and they need no SEO at all). Which lines up perfectly: article bodies go SSG, dashboards stay CSR.
So what about Next.js? I did have the migration from React evaluated, but in the end I didn't pick it. My grudge against Next had already been accumulating on other projects: a Tauri desktop app built on Next whose builds were so slow I couldn't stand it; the NAS frontend was worse — it got compromised through an hole in Next 16.0.6 and had a crypto miner planted in it.
The evaluation put it behind TanStack Start, so the framework shortlist collapsed to TanStack Start vs framework mode. As for Next's signature , it is useless here: all my data lives in the Rust backend, so a server component would at best await fetch(rustApi) — the RSC benefit cancels straight out.
What really tipped the scales toward was one single thing:
NOTE
It was "I want ISR" that flipped it
React Router 7's framework mode very nearly won. I was already on RR7, and flipping it into framework mode to get SSG was by far the lowest-friction migration (those 83 <Link>s and 24 useNavigates would have moved over almost untouched). But RR7 framework mode has no native ISR; wanting ISR means accepting that extra Node render layer in prod anyway. And I really did want ISR (publish and it refreshes, no waiting for a rebuild), plus "take the trendiest TanStack suite for a full spin" is perfectly legitimate dogfooding motivation for a personal vessel project like this one. That settled the router question in Start's favour.
Worth noting how new all of this is: TanStack Start v1.0 only shipped in March 2026, Vite is on 8.x (with Rolldown underneath), and the server engine below it, , is still on a v3 beta (TanStack Start integrates it directly). The whole stack is bleeding edge. As you'll see later, that newness was both the fun part and something that bit me several times.
What about , which ships even less JS? It got skipped in round one because I have heavy interactive things like three.js and Monaco (React-first is the realistic call); later, chasing "drop the eval and turn CSP on", I evaluated it seriously one more time, and I'll get to that conclusion below. The final mental model is blunt: "an SSG/ISR first paint plus SPA-style navigation afterwards". The initial load is HTML the server pre-generated (the source of the SEO and first-paint dividend), and after in-site page switches still feel like an SPA. You get both halves.
Implementation: bake the body into the HTML, keep heavy interactivity on the client#
A PoC first: run the prerender against a real post and confirm that the body really is baked into the HTML. /blog/39/index.html came out at 38KB, with a complete <article>, headings and tables inside the body. Four out of four green. The core conclusion in one line: prerender the body, leave heavy interactivity on CSR. When I built it for real, the render pipeline looked like this:
Two key decisions. The first is letting the API list which pages to prerender: instead of hard-coding five /en/blog/:id routes, one dynamic route $locale/blog/$id handles every language; at build time it hits /api/posts for each post's available_locales and only generates the languages that actually exist, with generated from the same source, never faked.
The second is baking the body into the HTML and keeping the heavy interactivity on the client: that eighteen-hundred-line interactive BlogPost, the one that pulls in mermaid and shiki, goes through lazy + , which guarantees that things that explode inside Node — three.js, mermaid — never enter the server bundle.
This road came with a string of server-bundle and hydration potholes. A few representative ones:
- LinkCard dragged the entire BlogPost down with it. Two pages,
HistoryandAboutSite, didimport { LinkCard } from './BlogPost'— LinkCard itself is perfectly SSR-able (it is a link preview card, it never touches window), but it was locked inside that two-thousand-line file together with mermaid, so a single import dragged the whole mermaid bundle into the server bundle. The fix was extracting LinkCard into its own SSR-safe module, decoupled from mermaid. - The nav bar speaking Korean: . The site-wide shell (Header/Footer) hangs off
__rootbut sits outside each page'sLocaleProvider, so the shell's translations fell back to the global i18next instance. Prerendering several pages together leaked languages between them: the SSR HTML said<html lang=en>and the hero was English (correct), while the nav bar was Korean. Nav bar ko on the server, nav bar en on the client → #418. The fix: wrap a provider at the root that derives the language from the URL, so the shell gets the right locale too. - The old service worker refusing to die. The old SPA had a PWA plugin registering a SW that precached the old assets; the new architecture has none. Returning visitors got the old shell plus the new HTML → broken styles plus #418. The fix: have the serve layer ship a self-terminating
/sw.js(unregister, clear caches, reload).
A few finer potholes on the render path are worth recording too, because every one of them comes back to "SSR and the client have to match character for character":
- A colon in the title auto-splits main title from subtitle. The schema has no subtitle field, so a
splitTitleon the frontend cuts at the first colon: the main title goes intoh1, the subtitle intop. Purely presentational —document.titleandog:titlestill use the full title, so SEO is unaffected. - Footnote ids hijacked the scroll-spy. The originally tracked "every element on the page that has an
id", and theuser-content-fn-…ids from footnotes and alerts stole the active state, so the TOC highlight simply vanished. The fix: only look at the set of heading ids the TOC actually lists. I also addedscroll-margin-topto the anchors so headings don't end up buried under the sticky header after a jump. - mermaid's ELK layout crashes with circular JSON on an SSR site. Switching to the Adaptive (ELK) layout threw
Converting circular structure to JSONoutright. Digging to the bottom:@mermaid-js/layout-elkrunsJSON.stringifyover the entire ELK graph, and this site's<html>is rendered by React through TanStack Start, sodocumentElementcarries a__reactFiberproperty — serializing it walks straight into a circular reference. This is a pothole specific to React SSR sites. The fix is a reference-counted guard that temporarily swapsJSON.stringifyfor a version that skips DOM nodes while a render is in flight.
The prerender produced 111 files, and not one of them was ever served#
Halfway through the implementation I ran into an absurd discovery. The prerender inside the docker build ran fast and clean (7.8 seconds, 111 files, zero errors), and .output/public/blog/index.html really was written out — but actually requesting /blog/index.html always came back 404, and hitting /en twice in a row gave two different md5s. Which means: every single request was re-running SSR, and not one of those 111 files had ever been served. The root cause is that Nitro's list of registered static assets is scanned before the prerender writes its files. Generating a pile of files nobody serves is pure wasted build time — and it forced the build to reach out to the live site for the post list.
So I ripped the prerender out entirely and went pure ISR. A quick primer on the machinery of ISR is in order here, because the official route doesn't work on my setup:
WARNING
The official ISR runs on a CDN; self-hosted with no CDN means nothing ever regenerates
TanStack Start's official ISR mechanism is: prerender at build time and stamp Cache-Control: stale-while-revalidate onto the response — and the background regeneration is performed by the CDN. I self-host nginx, DNS-only, deliberately not sitting behind anyone else's CDN proxy. With no CDN, that header has nobody to execute it, and background regeneration simply never happens.
What saves it is Nitro's : it has SWR built in, one line of routeRules does the job, and the background regeneration happens inside my own server:
// vite.config.start.ts —— ISR in one line, instead of 100 lines I write myself
const ISR_ROUTE_RULES = {
...swrRules(ISR_PAGES.flatMap(localeVariants), 3600), // UI pages: 1 hour
...swrRules(localeVariants('blog'), 300), // list page: new posts should show up soon → 5 minutes
...swrRules(localeVariants('blog').map((p) => `${p}/**`), 3600), // post pages: content barely changes → 1 hour
};The site's whole request flow now looks like this — Rust is the only real backend, and Node/Nitro is just a rendering shell (which is exactly my "all business logic in Rust, the Node layer in front only renders" definition landing in practice):
Three potholes in this stretch are worth unpacking:
The list page cached an empty shell. The ISR-cached /blog was empty at first, because the Blog component fetched its data inside useEffect, and useEffect does not run on the server → SSR emitted nothing but a loading skeleton (prerender wouldn't have saved it either; it doesn't run useEffect any more than SSR does). The fix was moving the fetch into the route loader and feeding the initial data in through useLoaderData: /blog's SSR output went from a 19,541-byte shell to 74,242 bytes, complete with titles and 8 post links.
Publish and it refreshes: on-demand regeneration. A TTL alone isn't enough; a new post needs to be visible to crawlers immediately. The approach is a Nitro server route /_revalidate (protected by a header secret; deliberately not under /api/*, because nginx sends everything under /api/ to Rust, so the frontend would never receive it), which the Rust backend hits fire-and-forget after a successful publish or edit to purge the cache. And I built it as an axum rather than wiring it into 14 write endpoints one at a time. Wiring them one at a time guarantees you miss one, and a miss doesn't error — it just quietly stops updating.
CAUTION
One word off and the entire site cache gets wiped
When deciding "this is a post-writing request", the intuitive path.contains("/posts") blows up very quietly: /api/posts/:id/view (which fires every time anyone reads any post) also contains /posts → every time someone reads a post, the entire site's ISR cache gets purged → ISR is effectively dead, and nothing reports an error. You have to explicitly exclude /view, /like, /reactions and /comments, and write a unit test that nails it down.
Cache rules use an allowlist, not /**. Wrapping the whole site (/**) is fail-open: any page added later that reads cookies or renders user data would be publicly cached by default, and nobody would notice. An allowlist is the opposite — a new page isn't cached unless you say so. Explicitly not cached: / (it reads cookies and Accept-Language to do language routing, so caching it means handing the first visitor's language to the whole world), /admin and /auth.
Side quest: the three stacked bugs that made every SSR page hang silently
The day I migrated to Nitro, every SSR page hung silently (returning 000, no error, no timeout), while the /api proxy and the static assets all returned 200. It turned out to be three bugs stacked on top of each other, each individually capable of taking the whole site down, and the deadliest one was also the sneakiest: nitro@3.0.0 was a nine-month-old release. package.json said ^3.0.0-beta, and semver's prerelease matching rules can never match those date-numbered betas (latest = 3.0.260610-beta). This is the concrete bite behind that earlier remark about the whole stack being bleeding edge. The old version had a routeRules.swr × ssr-renderer conflict that routed requests back into themselves → an infinite self-loop. Upgrading to the latest turned everything green.
The second half is the part worth remembering: at one point I thought it was fixed, then stopped and asked myself, "are these fixes real, or am I just routing around the symptom?" — an ablation test showed that only 2 of the 5 "fixes" were real (upgrading nitro, and pointing SSR at the local backend), while the other 3 (server.ts, noExternals, the /api proxy) were all working around the old version's bug and are simply unnecessary on the new one. The /api proxy was even "solving a problem that didn't exist". The config finally collapsed back to the official minimum, plugins: [tanstackStart(), viteReact(), nitro()] plus one SWR allowlist. Routing around something looks an awful lot like fixing it; actually fixing it starts with admitting you might have only routed around it.
MDX: a pipeline that evals, and a debt I want to repay#
Up to this point posts were still rendered with react-markdown. Then I wanted custom blocks like <Note>, <Annot>, <Diff> and <Chart> (to make the reading experience immersive enough), so I layered on an pipeline (opt-in per post; only posts with format=mdx go through it). This pipeline is the most technical stretch of the post, and my own understanding of it needed correcting right from the start:
IMPORTANT
The eval isn't brought in by interactivity, it is brought in by "there is runnable JS inside the post"
I used to think "only interactive blocks need eval", which is imprecise. react-markdown parses (string into an AST, then mapped onto components) and executes nothing; MDX "compiles to JS and then runs it": an inline expression like {new Date().getFullYear()} inside a post is real JavaScript, and that compiled JS has to run in the browser. So whether you trip over eval depends on "does the post contain runnable JS", not "is there interactivity".
Compilation happens on the server only. @mdx-js/mdx's compiler is micromark + acorn, which is heavy and has no business in the client bundle. So I use TanStack's : during SSR it runs in-process, and on client-side navigation it makes an RPC back to the server for the compiled result, producing a function-body string (serializable, dehydratable into the HTML). Execution happens on the client: the frontend takes that string and runs it into a React component with runSync — and runSync is new Function underneath, which is .
// server-only: the compiler is heavy and stays out of the client bundle; the function-body string it emits is serializable
export const compileMdx = createServerFn({ method: 'POST' })
.handler(async ({ data: source }) => {
const { compile } = await import('@mdx-js/mdx');
return String(await compile(source, { outputFormat: 'function-body', remarkPlugins: [remarkGfm, remarkAlert] }));
});
// client: runSync synchronously turns it into a component (works in SSR and hydration); ⚠️ new Function underneath = eval
const { default: Content } = runSync(compiled, jsxRuntime);A word about the editor detour along the way: I did once install MDXEditor (the only MDX-native WYSIWYG editor on the market) as a spike, but it has 131 dependencies and rendered all my custom blocks as a generic row of "gear icon + label" UI rather than the real components — and I prefer the way Monaco looks anyway. Then it clicked: MDXEditor was never a prerequisite for making MDX work. I write MDX source in Monaco and the renderer (compileMdx + runSync) paints it out; that is enough. The whole spike was backed out cleanly and Monaco stayed. A few other decisions: MDX falls back to markdown automatically when compilation fails (one fat-fingered tag shouldn't blow up an entire post); MDX posts and markdown posts share the same set of base components (shiki highlighting, mermaid, link cards, heading anchors).
That debt I want to repay sits right on this runSync:
NOTE
I haven't actually turned CSP on yet
The eval is under control for now: the content is all written and reviewed by me, not user submissions. And it is a little funny: I haven't even turned on yet, because __root.tsx contains an inline anti-flash/intro script and flipping CSP on carelessly would break the site — I'd have to nonce it first. So all those unsafe-eval comments really mean "the day I want a strict CSP, this eval will force me to allow unsafe-eval". I don't want to keep it around.
So how do I get rid of this eval? My first idea was : rewrite every interactive block as "CSS plus one bundled piece of vanilla JS", and mount the heavy ones through a registry. But that means rewriting a great deal, with behavioural regression risk. Then a far cleaner path clicked. The eval exists only because I compile MDX into function-body (a format that can only run through new Function). If I compile into a real ES module instead, the client can load it with import() — and a same-origin import() is allowed under a strict CSP script-src 'self' (that is module loading, not eval). The entire pipeline, every block and every animation stays untouched: zero rewrites, zero regressions. Islands are just the fallback if this one hits a wall. The target flow looks like this:
(This one hasn't been started; it is filed in the backlog. For the same goal I went back and evaluated Astro seriously: it is islands-native and naturally CSP-friendly, but Astro is a standalone framework with its own build, and it cannot be embedded into my existing TanStack Start. "Use Astro for the blog only" in practice means splitting the site into two apps: the shared header, TOC, reactions and comments would all have to be rewritten as islands, and i18n, ISR, SEO and the Rust data layer would all have to be rebuilt. That is a sledgehammer for a nut, and you have to demolish the kitchen to swing it. The dynamic-import route is worth vastly more per unit of effort.)
No more flash on the first frame: the long march of entry-render debugging#
All of the above shipped, but what really kept me grinding into the small hours was "the instant you enter a post". This whole section is render-timing potholes, and it is the heart of the post. Results first. On the left is how bad it was at the time; on the right is after the cleanup:
The first thing I noticed was that entering a post flashed for a split second, and stopped flashing once Query had it cached. But Query wasn't the truth: that ClientOnly fallback first emitted a body in plain sans-serif with zero article CSS, and only once the heavy FullBlogPost chunk (carrying BlogPost.css + shiki + mermaid) had loaded did the whole thing get swapped in. That is a full repaint, not a stylesheet snapping into place.
The first fix was prewarming that chunk on idle, which rescued in-site navigation; but pasting a URL and opening it cold still flashed, because ClientOnly always sends the fallback first. The second version went after the cause: rebuild the fallback with exactly the same structure as FullBlogPost and pull in the same CSS — with JS entirely switched off, the first frame is already a fully styled article.
That intermediate version — "add styles onto the existing HTML" — looked wrong to me too. What I wanted was a skeleton load: the whole layout, sidebar and TOC included, appears as a frame on the first paint, and each region fills itself in with its own loading state. So I added shimmer skeletons: the sidebar and TOC come up as skeletons, while the main body is still the real thing (SEO can't be sacrificed).
What I was seeing was clearly "the page element wipes it, then re-runs the insert animation". The root cause is that the same body was rendered twice: BlogPostPage (from SSR) → then FullBlogPost (inside ClientOnly) mounts and renders it a second time on top. Because those are two different component trees, React can only tear down and rebuild = a visible "wipe, then insert". I treated it in two stages:
Tier 1 (make the handoff invisible): switch the entry animation to initial={false} (don't treat content that is already there as a fresh load and slide it in all over again), compute the TOC directly inside the loader (server-side and free, since the body is already SSR'd), then extract the heading-extraction and reading-time logic into a shared lib so both versions match character for character. The handoff became roughly 95% seamless.
Tier 2 (the real thing): first sweep the entire render tree and flush out every SSR blocker, then go into that eighteen-hundred-line file: make FullBlogPost SSR-safe (localStorage becomes "SSR supplies a default, useEffect reads the real value afterwards", dates get an explicit timezone, window access gets wrapped in guards, mermaid's zoom becomes a small ClientOnly island) → then remove the outer ClientOnly entirely → and it becomes a single SSR render that hydrates in place. The double render disappeared at the root: zero skeleton residue, zero hydration errors, and BlogPostPage became dead code on the spot.
Reload a post and, as the anchor pulls the view back into position, it goes up, snaps down, goes up again, and stutters. My gut said "it feels like something is holding onto it, rather than a height calculation problem" — the gut was right, and I still spent a while fixing in the wrong direction.
The real cause was two things stacked: the global html { scroll-behavior: smooth } plus the on .post-content guessing the height wrong (it guessed 1200px against a real height of about 9648px, off by a factor of 8). And scroll-behavior: smooth turns every programmatic scroll into an animation, so each new call interrupts the previous one and restarts from where it stands → it never reaches the target; the scrollTo used for anchor restoration was being dragged the whole way. After A/B measurement I called it: drop the global smooth, and specify smoothness explicitly in JS in the places that genuinely want it, like TOC clicks and back-to-top.
Once it was a single render, the entry animation could come back — but the body text's animation has to use CSS @keyframes, not framer-motion's initial={{opacity:0}}. The reason is hard-nosed: Chrome's does not count elements at opacity:0. Fading the body in from transparent with framer effectively pins LCP to after hydration. So the body uses a CSS transform (never touching opacity), and framer is kept for exits, staggers and other places that don't affect LCP.
Writing all this made me curious where everybody else's content site ended up landing:
Wrapping up: the ledger of three rewrites#
This render long march really breaks down into three ledgers at three different levels:
- SEO and the first paint are the dividend of SSG/ISR; hydration is the whole new class of bug you buy with it. From here on, article bodies, titles and hreflang really are rendered into the HTML, and crawlers see them without running JS; the price is one more Node render shell, plus a kind of bug that didn't exist before: "what the server sends and what the client paints must match character for character".
- Measure, don't guess. The single most valuable thing that night was forcing myself to stop and ask "is this really fixed, or did I just route around it / guess right?". It pushed back two diagnoses I had made on impressions (the fake fixes for those three bugs, and the scroll serialization bug), and made me measure the truth out with ablation tests and Playwright.
- A few rules that will bite you over and over:
useEffectdoes not run on the server, so any page that fetches inside it will only ever emit an empty shell from both SSR and prerender; a fail-open site-wide cache rule is a security landmine; and if you purge caches by path prefix, remember to exclude the high-frequency endpoints.
There are a few debts still outstanding, listed honestly: MDX's runSync is a client-side eval — harmless for the moment, given that CSP isn't even switched on yet, but it is the thing I want gone before I go to a strict CSP. The plan is to compile MDX into an ESM module and replace runSync with import() (islands are only the fallback); the ISR cache still lives in memory, so every deploy or restart zeroes the whole site (no fs driver wired up yet); and the old SEOHead (react-helmet) hasn't fully retired either. Rendering is probably a question you never finish answering, but at least now, for every step a post takes from the database to your screen, I know why it looks the way it does.
- TanStack Start —— the full-stack framework (SSR / server functions / Nitro)Official site
- MDX —— writing JSX inside MarkdownOfficial sitecompile / run
- Nitro —— route rules and ISR / SWRRoute Rules
No comments yet
✨ Be the first to comment