Lazy-load blog content in SolidStart without losing SSG
Your blog probably ships every post's body in a single bundle — mine had grown to 2.88 MB, downloaded even on the index page where you only need titles. Here's how I split each post's prose into a lazy chunk that loads on demand, while every page stays fully server-rendered for SEO.
Keeping content in typed code is convenient — until the pile grows big enough to bloat what every visitor downloads. This is how I split a 2.88 MB blog into per-post chunks in SolidStart, without giving up a single server-rendered page.
One chunk to rule them all
My blog registry did the obvious thing: statically import every post, put them in an array, export a few helpers.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Each BlogPost carried its full body — a ContentBlock[] — for all 13 locales. Static imports mean the bundler pulls all of it into one chunk. The result:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBThat 2.88 MB shipped on every blog page — including the index, where all you render is a list of titles and descriptions. Every visitor downloaded all 19 posts' prose, in 13 languages, just to read one.
The split: metadata in the bundle, prose in a sidecar
The fix is to separate what the listing needs (cheap) from what a single post needs (expensive). Every post file became two.
The light half — <slug>.ts — keeps only metadata plus a function that imports the heavy half:
import type { BlogMeta } from "../types";
export const fitText: BlogMeta = {
slug: "fit-text-to-container-pure-css",
date: "2026-07-24",
readingTime: 11,
tags: ["CSS", "Container Queries", "Typography"],
translations: {
en: { title: "Fit text to its container…", description: "…" },
// …12 more locales, title + description only
},
loadContent: () => import("./fit-text-to-container-pure-css.content"),
};The heavy half — <slug>.content.ts — holds the prose, and nothing imports it statically:
import type { ContentBlock } from "../types";
import type { Language } from "~/i18n/languages";
export const content: Record<Language, ContentBlock[]> = {
en: [ /* the whole article, as blocks */ ],
// …12 more locales
};The BlogMeta type is the contract that glues them:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Because loadContent is a dynamic import(), the bundler gives each post's body its own chunk that is only fetched when someone calls it.
The registry: sync summaries, async content
The registry now imports only the light halves and exposes two kinds of access — synchronous metadata, asynchronous body:
export function getPostSummary(slug: string, lang: Language) {
const meta = metas.find((p) => p.slug === slug);
return meta && { ...meta, localized: meta.translations[lang] };
}
export async function getPostContent(slug: string, lang: Language) {
const meta = metas.find((p) => p.slug === slug);
if (!meta) return undefined;
const mod = await meta.loadContent(); // ← the lazy chunk loads here
return mod.content[lang] ?? mod.content.en;
}The index page and cards call getPostSummary/getAllPosts and never touch a body. Only the post page reaches for getPostContent.
The page: SEO from the summary, body from createAsync
The post route renders in two speeds. The header, title, tags, and every <meta>/JSON-LD tag come from the summary — synchronous, always there. The body comes from createAsync, which loads the chunk:
const post = () => getPostSummary(params.slug, lang());
const content = createAsync(() => getPostContent(params.slug, lang()));
return (
<Show when={post()} keyed>
{(p) => (
<article>
<PageSeo customTitle={p.localized.title} /* …sync SEO… */ />
<header>{/* title, tags, date — sync */}</header>
<Suspense fallback={<Skeleton minutes={p.readingTime} />}>
<Show when={content()}>
{(blocks) => <BlogPostRenderer content={blocks()} />}
</Show>
</Suspense>
</article>
)}
</Show>
);The part everyone gets wrong: is this still SSG?
Here's the question that stops people: there's a Suspense fallback — so won't Google index the skeleton instead of my content?
No. And the reason is worth understanding, because it's the whole load-bearing beam.
Suspense shows its fallback only while the async is pending — and pending only happens where the data isn't ready yet. During prerender, that's never true:
- On the server, SolidStart awaits the resource before it renders. By the time HTML is produced,
content()is resolved, so the fallback branch is never taken. The static file contains the full article. - SolidStart then serializes the resolved value into the hydration payload. On a direct visit, the client resumes the resource straight from that payload — it does not re-run
getPostContent, so it doesn't even fetch the content chunk.
You can prove it against the built output. Grep one prerendered post:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneZero skeleton. Full body. The content chunk isn't even linked — the prose rides in the DOM and in the ~20 KB serialized hydration script.
The skeleton is a spinner. A server never ships a spinner — it holds the response until the data is ready, then ships the finished page. Crawlers get the finished page.
Two paths, and only one is async
Splitting doesn't weaken SSG; it adds a code-splitting layer on top of it. There are two distinct paths, and the lazy chunk only matters for one:
| Who | What they get | Content chunk? |
|---|---|---|
| Google / direct visit / F5 | Full prerendered HTML, hydrates from the serialized payload | Not fetched |
| A visitor clicking through the app (SPA nav) | Route renders client-side, createAsync fires the import() | Fetched on demand |
The async cost is paid only by someone already inside the loaded app, navigating between pages — which is exactly who benefits from the 108 KB main bundle.
Making the async invisible
For that one SPA-navigation path, two touches remove the seam.
Size the skeleton to the post. At skeleton time the prose isn't loaded (that's the point), so the only length signal you have synchronously is readingTime. Render one paragraph-group per minute so a short post reserves little and a long one reserves more — the footer stops jumping:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Preload on intent. Warm the chunk the moment the pointer or keyboard lands on a card, so the click meets an in-flight (or cached) import instead of a cold one:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>A one-promise-per-slug cache makes the preload and the click share the same import(), so the chunk is never fetched twice. With preload in place, the skeleton is a fallback for the rare cache miss (slow network, tap without hover) rather than the common case.
Results
before after
main blog chunk 2,880 kB 108 kB (-96%)
post body in the chunk 19 lazy chunks, ~100-280 kB each
prerendered pages 247 247 (unchanged)The index and every non-post page now ship 108 KB instead of 2.88 MB. Open a post directly and you get fully server-rendered HTML with the content chunk never requested. Navigate to it inside the app and its body streams in — usually already preloaded.
Takeaways
- Split content by what each view actually needs: listings want metadata, a post wants its body. Don't make the index pay for 19 articles.
- A dynamic
import()behind a typedloadContent()is all it takes to give each post its own chunk. - Lazy loading and SSG are not in tension.
createAsyncresolves on the server and serializes into the hydration payload, so prerendered HTML stays complete and the fallback never ships. - The remaining seam — client navigation — is UX, not SEO: a
readingTime-sized skeleton and an intent-based preload make it disappear.