Products

The Solution I Found When I Cannot Use the SSR in My Cheap Hosting

This is a story about a small idea that turned out to be bigger than I expected. It is about the evening I opened my own blog, viewed the page source, and felt a little embarrassed...

This is a story about a small idea that turned out to be bigger than I expected. It is about the evening I opened my own blog, viewed the page source, and felt a little embarrassed by what I saw.

It started with a blank page

I was proud of my site. It is a Laravel app with Inertia and Vue on the front, which means I get to write components and never think about wiring JSON by hand. Everything feels modern and quick when I am the one clicking around on a fast laptop.

Then I viewed the page source of one of my articles. And there it was: an empty shell. A <div id="app">, a blob of JSON, and nothing else. No <h2>, no paragraphs, no article. The words I had spent hours writing existed as a string inside a script tag, waiting for JavaScript to wake up and paint them.

I sat with that for a minute. I had built a beautiful house, but the delivery truck was dropping off a locked box at the door.

What I actually wanted

The thing is, I did not want "SSR" as a checkbox. I wanted three specific feelings:

  • When someone shares my article on X or WhatsApp, I want the preview to be real.
  • When a reader opens it on a slow train connection, I want words on the screen before the bundle finishes downloading.
  • When a machine — a search engine, an AI crawler — reads my page, I want it to read the article, not a JSON payload.

My metadata was already rendered on the server, so link previews looked fine. But the article body itself only appeared after hydration. That gap was the problem.

The roads I considered

I am a practical person, so I started listing the options I knew:

Option Article in the first response? What it would cost me
Just keep it as it is No Nothing — and nothing gets better
Full Inertia SSR Yes A Node process running on my host
Static pre-rendering Yes, until the next publish Stale pages, and a rebuild step I can't run on my server
Render only the article on the server Yes A small amount of extra work

Full SSR was the textbook answer, and it was the first one I crossed out. My hosting cannot even create a symlink; asking it to keep a long-lived Node renderer alive was out of the question.

Static pre-rendering teased me for a while. It is simple in theory and very fast. But then I asked myself the honest question: when I press Publish in my own admin at midnight, who regenerates that static file? Without Node on the server, nobody. My fresh article would silently become yesterday's snapshot.

So the last row was the one that refused to leave my head: I don't need to render the app, I only need to render the article.

The shift: my content was already HTML

Here is the thing I had forgotten. Earlier in this project I made a decision for the rich text editor: article bodies are stored as sanitized HTML, not Markdown. That decision was about editing, but it quietly solved this problem too. By the time a page renders, my server is already holding the finished article markup and its table of contents. It was building all of it — and then handing it to Vue to draw, while sending an empty page to everyone else.

The fix, once I saw it, felt almost silly: stop throwing that HTML away. Put it in the first response, ahead of the app.

Making it safe to trust

Before I let raw HTML into a Blade template, I needed to be sure it was clean. Every body is sanitized on write against a strict allowlist, so scripts, inline event handlers, and javascript: URLs never survive, and headings get stable slug IDs I can link to:

$content = $this->sanitizer->clean($html);
[$html, $toc] = $this->addHeadingAnchors($content);

Because the stored HTML is already clean, echoing it into the template became a deliberate, bounded decision instead of a gamble.

Building the island

My article controller was already computing the HTML and the table of contents for the Vue props. I reused the exact same data as a small ssrIsland payload and printed it before the app in my root template:

@if ($island = $page['props']['ssrIsland'] ?? null)
    <div id="ssr-island" class="ssr-island">
        @include('partials.article-island', $island)
    </div>
@endif

I kept it deliberately small. The island is only the article region — the header, the footer, the comments, all the interactive parts still belong to Vue. I was not trying to build a second frontend; I just wanted the words to arrive first. The partial reuses the same classes as my Vue renderer, so both versions look identical.

I called it an island because that is exactly what it feels like: a solid piece of server-rendered land floating just ahead of the app.

Handing the baton back to Vue

An island is only useful if it knows when to hand over. The moment Inertia mounts, the first thing I do is remove the server copy:

createApp({ render: () => h(App, props) }).use(plugin).mount(el);
document.getElementById('ssr-island')?.remove();

Now there are two quiet outcomes. If someone turns off JavaScript, they still get the whole article and its table of contents. If they have JavaScript, they see the island immediately and Vue takes over without a visible seam. The reader never sees both; they never see the swap.

What it gave me

The night I shipped it, I opened dev tools and viewed source again. This time the article was right there — headings, paragraphs, the "Daftar Isi" sitting quietly in the markup. It sounds like a small thing. It was not, to me.

  • Crawlers and no-JS readers get the real article now.
  • The text is on screen before the bundle finishes, so the first paint feels instant.
  • There is no new infrastructure to babysit — no Node at runtime, nothing to keep alive.
  • And because it renders per request, hitting publish updates it immediately. No stale snapshots.

What it didn't fix

I want to be honest about the edges, because I have been burned by blog posts that oversell.

  • The island renders the article region only. The rest of the page is still client-rendered.
  • It is scoped to article pages. Other pages behave exactly as before.
  • If I ever truly need the entire page server-rendered, I would still need a Node renderer.

SSR is a spectrum, not a switch. I took the piece of it I actually needed — the article — and left the cost behind.

What I took away

Three lessons stuck with me.

  • Store content in the shape you want to ship it. Saving sanitized HTML made this almost free. The decision I made for editing paid off for delivery.
  • Separate the content from the chrome. The article is static data; the app around it is interactive. Only one of them needed to be server-rendered to help a reader.
  • Design the hand-off, not just the feature. Keeping the island and the Vue component visually identical is what made the swap invisible. The magic is in the seam.

My host still cannot create a symlink. My stack is still Laravel, Inertia, and Vue. Nothing dramatic changed. But when someone opens my article now, the words are already there — and that small "yes" to a specific question turned out to be the most satisfying fix I have made in a while.