Streaming SSR Was Quietly Hurting My SEO. Here Is the One-Line Leptos Fix

Jord
Product Engineer & Founder
I rebuilt this site in Leptos, and a few weeks after launch I noticed something I should have caught earlier: my homepage wasn't doing its job as an internal link hub.
The homepage has a section called "The Index" that lists my three most recent articles. The whole point of it is to pass link authority to new posts and give crawlers a fresh, dependable path to them. Except when I actually looked at the HTML Googlebot receives first, those three links weren't there. There was a "Loading articles..." placeholder where they should have been.
The fix turned out to be one line. But the reason it was happening is worth understanding, because it's a trade-off baked into how Leptos renders by default, and it applies to any framework that streams HTML.
How the page is built
The article list isn't static. It's fetched through a server function and rendered inside a Suspense boundary:
#[server(GetHomeArticles, "/api")] pub async fn get_home_articles() -> Result<Vec<HomeArticle>, ServerFnError> { let articles = crate::content::get_all_articles(); Ok(articles.into_iter().take(3).map(/* ... */).collect()) }
<Suspense fallback=move || view! { <div class="col-span-3 p-16 text-center text-gray-500">"Loading articles..."</div> }> {move || { articles.get().map(|result| match result { Ok(arts) => arts.into_iter().map(|article| { view! { <HomeArticleCard article/> }.into_any() }).collect_view(), Err(_) => /* ... */, }) }} </Suspense>
This is completely idiomatic Leptos. A Resource drives an async fetch, Suspense shows a fallback until it resolves, and the real content swaps in. On the client it works exactly as you'd expect.
The interesting part is what the server does with it.
Out-of-order streaming, and why it's the default
By default, Leptos renders with SsrMode::OutOfOrder. The server starts streaming HTML immediately, without waiting for your resources to resolve. When it hits a Suspense boundary whose resource isn't ready, it writes the fallback into the document at that position and moves on. Once the resource resolves, the real markup is streamed down later as an out-of-order chunk and patched into place with a bit of inline script.
This is a genuinely good default. It means time-to-first-byte doesn't wait on your slowest query. The user sees the shell of the page — nav, hero, layout — almost instantly, and the data-dependent bits fill in as they're ready. For most pages, that's exactly what you want.
The catch is what lands in that first flush of HTML. For my homepage, the initial response contained:
<div class="col-span-3 p-16 text-center text-gray-500">Loading articles...</div>
...sitting in the DOM position where my three article links were supposed to be. The actual <a href="/articles/..."> tags arrived in a later chunk.
Why this matters for crawlers
The usual objection here is "Google renders JavaScript now, so it'll see the patched-in links anyway." That's true, and to be clear, this wasn't broken — the links were reachable. Google's second render wave does execute the page and would pick them up.
But "reachable after a JS render pass" and "present in the initial HTML" are not the same thing, and the difference matters most for exactly this kind of page. The homepage is the highest-authority page on the site and the one I most want to reliably funnel that authority through to new posts. I don't want those internal links sitting behind a render queue and a patch step. I want them inline, in their proper position, in the bytes the crawler gets on the first request — no JS execution required.
It's the difference between a dependable internal link source and a probably-fine one. For the single most important page on the site, I'd rather not gamble on "probably."
The fix
Leptos lets you set the SSR mode per route. So I didn't change anything global — I just changed the mode for the home route:
<Routes fallback=|| view! { <NotFoundPage/> }> <Route path=path!("/") view=HomePage ssr=SsrMode::Async/> <Route path=path!("/about") view=AboutPage/> // ...every other route stays on the default </Routes>
SsrMode::Async flips the trade-off. Instead of streaming the shell first and patching the data in later, the server waits for all the route's resources to resolve, then renders the fully-resolved HTML in one shot. The three article links now land inline, in their natural DOM position, in the initial response. No fallback, no patch chunk, no JS required to see them.
"But you just gave up streaming"
Yes — for this one route, on purpose. That's the part worth being honest about, because Async is not a free upgrade. You're trading time-to-first-byte for completeness. The server now blocks on your resources before sending a single byte, so if those resources are slow, your TTFB gets worse for everyone.
The reason it's the right call here is that the resource isn't slow. get_home_articles doesn't hit a database or an external API — it reads a pre-parsed, in-memory content index and takes the first three. The "wait" I'm paying for is effectively nothing, so blocking on it costs me nothing and buys me clean, crawlable HTML.
That's the whole decision rule. Async is worth it when the resource is cheap and the inline HTML matters. It's a bad trade when the resource is genuinely slow, because then you're making real users stare at a blank page to satisfy a crawler.
A mental model for the SSR modes
The thing I'd take away from this isn't "use Async." It's that the rendering mode is a per-route decision, and the right one depends on what that specific route is for:
OutOfOrder(default): stream the shell, patch data in as it resolves. Best for most pages — content-heavy routes where you want fast TTFB and the exact byte position of data in the initial HTML doesn't really matter.InOrder: stream sequentially, pausing at each unresolved boundary. A middle ground when source order matters but you still want streaming.Async: resolve everything, then render once. Reach for it on routes where the initial HTML needs to be complete and self-contained — your highest-value landing pages, and anything that exists to feed crawlers reliable internal links.Sync: render immediately with no waiting and let resources hydrate purely on the client. Fine for dashboards behind auth where SEO is irrelevant.
You don't have to pick one for the whole app. That's the part I'd underused — I'd been treating "how does this framework do SSR" as a single global answer when Leptos lets you answer it route by route.
How to actually check this
Don't use your browser's devtools element inspector to verify any of this — by the time the inspector shows you the DOM, hydration and any patches have already run, so everything looks inline whether it is or not. You have to look at the raw bytes the server sent.
curl -s https://yoursite.com/ | grep -A2 "article"
Or just View Source (not Inspect) and search for the content you care about. If you see your real links and text, you're good. If you see a loading fallback where your content should be, you're streaming it in later — and now you know which knob to turn.
Final thoughts
This is a small fix — one enum on one route — but it's the kind of thing that's invisible until you go looking, and it stayed invisible to me for weeks because the site worked. Everything rendered, every link clicked through. The problem only showed up when I stopped asking "does it work in my browser" and started asking "what exactly does the crawler get on the first request."
Streaming SSR is a great default. Just remember it's a default, not a law, and the one page you most want crawlers to trust is probably the one page where you should turn it off.
Stay in the loop.
Weekly insights on building resilient systems, scaling solo SaaS, and the engineering behind it all.