I went through CSR, SSR, SSG, ISR, streaming and RSC, and what I picked was half an SSR
CSR, SSR, SSG, ISR, streaming SSR, RSC and islands compared with code and trade-offs, and why I still chose a half version that injects meta on the server for the post page only.
#SSR #SSG #CSR #ISR #RSC #SEO #React
Out of curiosity, I opened the package.json of every service running on my server. At the time of writing there are eight: blog, crypto, couples, tarot, travel, game, monitoring and portfolio. blog-server/blog -> vite portfolio-server/final-portfolio -> vite coin-server/coinSite -> vite monitor-server/monitor-client -> vite couple-server/... -> vite travel-server/... -> vite game-server/... -> vite taro-server/... -> vite All eight are Vite. Not a single one uses next. It isn't a matter of conviction. Vite was simply what my hands were used to, so every time I started a new project I went with it without thinking twice. That went fine for a few years, and then recently the blog ran into a problem. AdSense looked at my blog and flagged it for "screens without publisher content" . This was a blog that, at the time of writing, had 21 posts averaging around 3,500 characters each. I wanted to know why, so I went back over rendering strategies from the beginning. In this post I go through CSR, SSR, SSG, ISR, streaming SSR, RSC and islands, with code and the pros and cons of each , and then explain why I ended up picking the most halfway option of them all. It comes down to one question: who builds the HTML, and when There are a lot of names, which makes it look complicated, but there are really only two axes: who builds the HTML and when it gets built . Every approach below is some combination of those two. Who builds it When it's built CSR -> browser after the user arrives SSR -> server on every request SSG -> build machine ahead of time, before deploy ISR -> build machine + server ahead of time, then again periodically Streaming SSR -> server at request time, but sent in pieces RSC -> server + browser differs per component Islands -> build machine + browser mostly ahead of time, only parts in the browser In the table they all look different, but the difference is only in how each one answers those two questions. Here they are one at a time, with code. 1. CSR: the server sends down an empty shell This is how my blog works. The build produces a dist folder, and Express does nothing but serve it as static files. // app.js - this is all of it app.use(express.static(path.join(__dirname, 'dist'))); app.get('*', (req, res) = { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); So the first HTML the browser receives looks like this. body div id="root" /div !-- empty -- script type="module" src="/assets/index-93b1bdd9.js" /script /body When I actually measured it, this index.html was 5,905 bytes . If the first HTML of a blog with 21 posts is under 6KB, that means not one character of the posts is in it. Nothing appears on screen until the JavaScript at the bottom (399KB gzipped) runs in the browser. The upside is that deployment is simple. You upload static files and you're done, so the server has nothing to do, and once the app has loaded, moving between screens is fast because nothing goes back through the server. You can put it on a CDN as is. The downside is that the first screen is slow and, more importantly, that crawlers see that empty div . Search engines and link preview bots don't wait around for JavaScript to draw the page. 2. SSR: the server renders it on every request SSR goes the other way: the server runs React, finishes the HTML and then sends it down. Written by hand without a framework, it looks like this. import { renderToString } from 'react-dom/server'; app.get('*', async (req, res) = { const posts = await db.getPosts(); // fetch the data on the server first const html = renderToString( App posts={posts} / ); // then build the HTML from that data res.send(` div id="root" ${html} /div !-- goes out with the content already filled in -- script window.__DATA__ = ${JSON.stringify(posts)} /script script src="/client.js" /script `); }); The thing to look at here is window.__DATA__ . The HTML the server produced is only text, so clicking a button does nothing. That's why React runs once more in the browser with the same data and attaches the event handlers. This step is called hydration . // browser side - only attaches events to the DOM the server built (it does not render again) hydrateRoot(document.getElementById('root'), App posts={window.__DATA__} / ); The upside is that crawlers see finished HTML. The first screen also shows up quickly, and content that differs per person, like login state, can be put in right on the server. The downside is that the server works on every request. As traffic grows, so does the server bill. On top of that, you end up sending the data twice : once as HTML, and again as JSON for hydration. And until hydration finishes there's an awkward stretch where the screen is visible but clicks don't work. 3. SSG: build everything ahead of time, before deploying Where SSR builds the HTML on every request, SSG builds it once at build time and keeps reusing the result. If I turned my blog into SSG, the script would look roughly like this. // runs once at build time const posts = await db.getPosts(); // 21 posts for (const post of posts) { const html = renderToString( Post data={post} / ); fs.writeFileSync(`dist/blog/${post.id}.html`, wrap(html)); } // -> dist/blog/49.html, 50.html ... 69.html are generated as whole files With those in place, all the server has to do after deploy is hand out files, because the rendering is already done. The upside is that it's the fastest and the cheapest. The server has nothing to compute, so load isn't a worry, and on a CDN it's fast from anywhere. The downside is that you have to rebuild every time the content changes . With tens of thousands of posts, the build alone takes tens of minutes. And content that differs per person can't go in at all. Everyone gets the same page. 4. ISR: build ahead of time, but rebuild periodically So how do you get around SSG's "rebuild whenever the content changes" problem? ISR is the compromise. It still builds pages ahead of time, but once a set amount of time has passed, it rebuilds just that page. // Next.js - this page is rebuilt every 60 seconds export const revalidate = 60; export default async function Page() { const posts = await getPosts(); return PostList posts={posts} / ; } Someone who arrives within the 60 seconds gets the prebuilt page. Someone who arrives after the 60 seconds still gets the old page first, and the server builds a new one in the background. The new page goes to whoever comes next. The upside is that you keep almost all of SSG's speed while avoiding full rebuilds. The downside is that you can't know exactly when your edit will show up . Even if you fix a single typo, somebody sees the old post for a minute. It can't be used for data that has to be real time. 5. Streaming SSR: don't wait until everything is rendered SSR has one weak spot. The single slowest piece of data holds up the whole page . If the comments API takes 3 seconds, the post body also goes out 3 seconds later. Streaming SSR solves this by sending whatever is ready first. import { renderToPipeableStream } from 'react-dom/server'; app.get('*', (req, res) = { const { pipe } = renderToPipeableStream( App / , { onShellReady() { res.setHeader('Content-Type', 'text/html'); pipe(res); // send the shell (header, body) right away }, }); }); // wrap only the slow part in Suspense, and only that part follows later article PostBody / {/* goes out right away */} Suspense fallback={ CommentSkeleton / } Comments / {/* sent later, once it's ready */} /Suspense /article The upside is that one slow piece of data doesn't block the whole page. The user can start reading the body in the meantime. The downside is that the server load is exactly the same as SSR. The structure also gets more complicated, and error handling gets tricky, because if something fails after the response has already started going out, you can't change the status code. 6. RSC: split server and client at the component level Up to here, the rendering strategy was chosen per page. RSC (React Server Components) brings that choice down to the component level . This component runs only on the server, that one gets shipped to the browser, and so on. // server component (the default) - no JavaScript is shipped to the browser async function PostList() { const posts = await db.query('SELECT * FROM Board'); // queries the DB directly return ul {posts.map(p = li key={p.id} {p.title} /li )} /ul ; } 'use client'; // only what carries this directive is shipped to the browser function LikeButton() { const [liked, setLiked] = useState(false); // has to react to clicks, so it goes to the client return button onClick={() = setLiked(!liked)} {liked ? '♥' : '♡'} /button ; } The main point is that server components are not included in the bundle at all . Even if the PostList above uses a heavy library like a markdown parser, that library is never sent to the user. It's also why you can write DB access code right inside the component. The upside is that the bundle gets a lot smaller. The data fetching code and the UI code also sit in the same file, which makes them easier to manage. The downside is that you have to keep one more boundary in your head . Once you start losing track of whether you're writing a server component or a client component, you add a single useState and the build fails. And in practice you end up tied to a framework like Next.js. 7. Islands: mostly static HTML, with only the needed parts brought to life This is the approach tools like Astro take. The whole page is built as static HTML, and only the parts that actually need to be interactive are hydrated separately, like islands. --- // Astro - this part runs only on the server, at build time const posts = await getPosts(); --- article set:html={post.content} / !-- plain HTML. 0 bytes of JavaScript -- SearchBox client:visible / !-- loads its JS when it becomes visible -- LikeButton client:idle / !-- loads when the browser is idle -- When you think about it, the body of a blog post never has to move. It's just text. Yet CSR downloads the entire React runtime to draw that text. Islands started from the feeling that this is a waste. The upside is that on content-heavy sites the JavaScript gets close to zero. For a blog it's pretty much the best fit. The downside is that passing state between islands is a hassle. It doesn't suit services where the whole screen has to move together. All of them side by side First screen Crawler Server load Updates Good fit CSR slow sees only empty div none immediate admin, dashboards, screens behind login SSR fast fine every request immediate live prices, personalized feeds SSG fastest fine none rebuild docs, blogs, about pages ISR fast fine occasional N-second lag news, product lists Streaming SSR faster fine every request immediate pages with a slow API mixed in RSC fast fine every request immediate apps with big bundles Islands fastest fine none rebuild content-heavy sites Laid out like this, the right answer for my blog was obvious. SSG or islands. There are only 21 posts, they don't change often, everyone gets the same content, and the body doesn't move. The textbook answer was right there. And still I left it as CSR. There's a reason. Why didn't I pick the right answer? The reason was in how the posts are stored. On my blog the posts live in a DB , and I write them from an admin page. If I switched to SSG, I'd have to rebuild and redeploy every time I edited a post. But my deploys aren't automated, so I type out the build and the pm2 restart by hand every time. I didn't want to repeat that just to fix one typo. If I had managed the posts as markdown files, it would have been a completely different story. In the end, the rendering strategy turned out to be tied to where the posts are stored . That's hard to see before you build the thing. If I were starting over from scratch now, I'd think about this part first. On top of that, moving the whole frontend to Next or Astro just for the blog's search visibility cost too much. All the more so becau