I went through CSR, SSR, SSG, ISR, streaming and RSC, and what I picked was half an SSR
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 filesWith 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 because the other seven services were running on CSR without any problem.
So I built only half of it
Instead, I changed the question. Which routes do crawlers actually come in through? When I counted, it was one: the post detail page. Search engines have no reason to look at the admin page, and the same goes for the login screen.
In that case, I only need to intercept that one route on the server. When a request comes in, it reads the post from the DB, swaps in the meta tags, and sends the body text along inside a noscript.
// strip the default meta from index.html and swap in per-post tags
html = html
.replace(/<title>[\s\S]*?<\/title>/i, '')
.replace(/<meta\s+property="og:[^"]*"[^>]*>/gi, '');
const tags = `
<title>${esc(post.title)}</title>
<meta property="og:title" content="${esc(post.title)}" />
<meta property="og:image" content="${esc(image)}" />
<link rel="canonical" href="${esc(url)}" />
`;
html = html.replace('</head>', tags + '</head>');
// it's an SPA so #root is empty, so the body text goes into a noscript
html = html.replace(/<noscript>[\s\S]*?<\/noscript>/i, `
<noscript><article><h1>${esc(post.title)}</h1><div>${esc(bodyText)}</div></article></noscript>
`);This is what I built. It isn't real SSR that runs React on the server. It's a half version that swaps tags in an HTML string with regular expressions. By the textbook it isn't pretty. But it was exactly as much as I actually needed.
Build it yourself and you go through everything the framework used to handle
There was a price, of course. The part that burned me most while building this was route registration order. In Express, whichever handler matches first from the top wins.
Just a few days ago I lost a good while to this. I was sure I had written the canonical to follow the domain the visitor came in on, but on the home page alone it kept coming out with the wrong address. The cause was this one line.
app.use(express.static(path.join(__dirname, 'dist')));
This line was quietly intercepting requests to /. express.static defaults to index: true, so when a request comes in at the root it serves dist/index.html on its own. The request never even reached the handler I had registered further down. In the end I turned off index on static and changed it so my handler takes the SPA routes.
// index:false - stops static from intercepting '/'
app.use(express.static(path.join(__dirname, 'dist'), { index: false }));
app.get('/blog/:bid', blogMetaHandler); // post detail gets per-post meta injected
app.get('*', spaMetaHandler); // everything else gets meta adjusted to the request hostWith Next.js I would never have had to think about any of this. I think this is the real cost of building it yourself. Not the time spent writing code, but the time spent stepping into these traps one by one.
There are things I haven't used
To be honest about it, RSC and islands are not something I've run in a real service. While putting this together I read the docs and ran the examples, and that's as far as it goes. So the downsides I listed above for those two are things I read in the docs, not things I went through myself.
What I have gone through myself is the downside of CSR, meaning the empty div, the crawler problem and the AdSense warning. And then the trap in hand-writing partial SSR, which is route order. The rest I plan to write about again someday, after I've actually used them.
From the fix to the result
I fixed the canonical and the noscript and deployed them in late August. But until Google crawls the site again, there's no way to know whether the fix worked. If I press the review button in the Policy center, it just judges again based on what it crawled before.
So I wrote a script that watches for when the crawlers come and ran it from cron every 20 minutes. It scans the Apache access log, sends an email the first time it sees either of the two things below, and records it in a state file so it doesn't send twice for the same thing.
# crontab */20 * * * * cd /path/to/portfolio-server && node scripts/adsense-crawl-watch.js >> logs/adsense-crawl-watch.log 2>&1 # send an email the first time either of these shows up in the log # - Googlebot crawls the fixed routes (/privacy, /portfolio) again # - an AdSense crawler (AdsBot, Mediapartners) shows up
I left out the Search Console live test on purpose. That one only comes because I pressed a button, so it has nothing to do with indexing. At first I saw it show up, thought "it's here" and got excited over nothing.
When I first published this post, no email had arrived yet. As of mid-September, when I'm revising it, the state file the script left behind shows two hits.
# scripts/.adsense-crawl-watch.state.json (excerpt)
"privacy": { "at": "27/Aug/2026:18:51:13", "ua": "Googlebot/2.1" }
"adsensebot": { "at": "08/Sep/2026:20:32:11", "ua": "AdsBot-Google-Mobile" }On the evening of August 27 Googlebot crawled the fixed /privacy again, and on the evening of September 8 a crawler from the AdSense side came. The August 27 one was picked up late, and I got both emails on September 9. And if I count the log by date, since the start of September Googlebot requests to the root domain have been coming in at dozens to hundreds a day. September 3 alone had 751. So at least it's confirmed that Google has looked at the fixed pages again.
Still, the crawler having come and the ruling having changed are two different things. A Policy center review only means something if I request it after these crawls have piled up, and at the time of this revision there's nothing I could call a result yet. When there is one, I plan to add it to the end of this post.