Blog-Inhalte in SolidStart lazy laden, ohne SSG zu verlieren
Dein Blog liefert vermutlich den Text jedes Beitrags in einem einzigen Bundle — meins war auf 2,88 MB angewachsen und wurde sogar auf der Übersichtsseite geladen, wo du nur Titel brauchst. So habe ich den Text jedes Beitrags in einen Lazy-Chunk ausgelagert, der bei Bedarf lädt, während jede Seite für SEO vollständig servergerendert bleibt.
Inhalte in typisiertem Code zu halten ist bequem — bis der Stapel groß genug wird, um aufzublähen, was jeder Besucher herunterlädt. So habe ich einen 2,88-MB-Blog in SolidStart in Chunks pro Beitrag zerlegt, ohne eine einzige servergerenderte Seite aufzugeben.
Ein Chunk, sie alle zu knechten
Meine Blog-Registry tat das Naheliegende: jeden Beitrag statisch importieren, in ein Array packen, ein paar Helfer exportieren.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Jeder BlogPost trug seinen vollständigen Text — ein ContentBlock[] — für alle 13 Sprachen. Statische Importe bedeuten, dass der Bundler alles davon in einen Chunk zieht. Das Ergebnis:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBDiese 2,88 MB wurden auf jeder Blog-Seite ausgeliefert — auch auf der Übersicht, wo nur eine Liste aus Titeln und Beschreibungen gerendert wird. Jeder Besucher lud den Text aller 19 Beiträge in 13 Sprachen herunter, um einen zu lesen.
Der Split: Metadaten ins Bundle, Text daneben
Die Lösung: trennen, was die Liste braucht (billig), von dem, was ein einzelner Beitrag braucht (teuer). Aus jeder Beitragsdatei wurden zwei.
Die leichte Hälfte — <slug>.ts — behält nur Metadaten plus eine Funktion, die die schwere Hälfte importiert:
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"),
};Die schwere Hälfte — <slug>.content.ts — enthält den Text, und nichts importiert sie statisch:
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
};Der Typ BlogMeta ist der Vertrag, der beide verklebt:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Weil loadContent ein dynamisches import() ist, gibt der Bundler dem Text jedes Beitrags einen eigenen Chunk, der nur geladen wird, wenn jemand ihn aufruft.
Die Registry: synchrone Summaries, asynchroner Inhalt
Die Registry importiert jetzt nur die leichten Hälften und bietet zwei Arten von Zugriff — synchrone Metadaten, asynchroner Text:
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;
}Die Übersichtsseite und die Karten rufen getPostSummary/getAllPosts auf und fassen nie einen Text an. Nur die Beitragsseite greift zu getPostContent.
Die Seite: SEO aus dem Summary, Text aus createAsync
Die Beitrags-Route rendert in zwei Geschwindigkeiten. Header, Titel, Tags und jedes <meta>-/JSON-LD-Tag stammen aus dem Summary — synchron, immer da. Der Text kommt von createAsync, das den Chunk lädt:
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>
);Der Punkt, den alle falsch verstehen: Ist das noch SSG?
Hier ist die Frage, die viele stoppt: da ist doch ein Suspense-Fallback — indexiert Google dann nicht das Skelett statt meines Inhalts?
Nein. Und der Grund lohnt sich zu verstehen, denn er ist der tragende Balken.
Suspense zeigt seinen Fallback nur, solange das Async aussteht — und ausstehend ist es nur dort, wo die Daten noch nicht bereit sind. Beim Prerendering ist das nie der Fall:
- Auf dem Server wartet SolidStart auf die Resource, bevor es rendert. Wenn das HTML entsteht, ist
content()bereits aufgelöst, sodass der Fallback-Zweig nie genommen wird. Die statische Datei enthält den vollständigen Artikel. - Danach serialisiert SolidStart den aufgelösten Wert in die Hydration-Payload. Bei einem Direktaufruf setzt der Client die Resource direkt aus dieser Payload fort — er führt
getPostContentnicht erneut aus und lädt somit nicht einmal den Inhalts-Chunk.
Man kann es am gebauten Output beweisen. Grep einen prerenderten Beitrag:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneNull Skelett. Vollständiger Text. Der Inhalts-Chunk ist nicht einmal verlinkt — der Text steckt im DOM und im ~20 KB großen serialisierten Hydration-Skript.
Das Skelett ist ein Spinner. Ein Server liefert nie einen Spinner aus — er hält die Antwort zurück, bis die Daten bereit sind, und liefert dann die fertige Seite. Crawler bekommen die fertige Seite.
Zwei Pfade, und nur einer ist asynchron
Der Split schwächt SSG nicht; er legt eine Code-Splitting-Schicht obendrauf. Es gibt zwei verschiedene Pfade, und der Lazy-Chunk zählt nur für einen:
| Wer | Was er bekommt | Inhalts-Chunk? |
|---|---|---|
| Google / Direktaufruf / F5 | Vollständiges prerendertes HTML, hydriert aus der serialisierten Payload | Wird nicht geladen |
| Ein Besucher, der durch die App klickt (SPA-Navigation) | Route rendert clientseitig, createAsync löst das import() aus | Bei Bedarf geladen |
Die Async-Kosten trägt nur, wer schon in der geladenen App ist und zwischen Seiten navigiert — also genau der, der vom 108-KB-Bundle profitiert.
Das Async unsichtbar machen
Für diesen einen SPA-Navigationspfad entfernen zwei Handgriffe die Naht.
Skaliere das Skelett zum Beitrag. Zum Zeitpunkt des Skeletts ist der Text nicht geladen (das ist ja der Punkt), also ist das einzige Längensignal, das du synchron hast, readingTime. Rendere eine Absatzgruppe pro Minute, damit ein kurzer Beitrag wenig und ein langer mehr reserviert — der Footer hört auf zu springen:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Vorladen bei Absicht. Wärme den Chunk in dem Moment vor, in dem Zeiger oder Tastatur auf einer Karte landen, damit der Klick auf einen bereits laufenden (oder gecachten) Import trifft statt auf einen kalten:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>Ein Cache mit einem Promise pro Slug lässt Vorladen und folgenden Klick dasselbe import() teilen, sodass der Chunk nie zweimal geladen wird. Mit Vorladen ist das Skelett ein Fallback für den seltenen Cache-Miss (langsames Netz, Tippen ohne Hover) statt der Normalfall.
Ergebnisse
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)Die Übersicht und jede Nicht-Beitragsseite liefern jetzt 108 KB statt 2,88 MB. Öffne einen Beitrag direkt und du bekommst vollständig servergerendertes HTML, wobei der Inhalts-Chunk nie angefordert wird. Navigiere in der App dorthin, und sein Text lädt nach — meist bereits vorgeladen.
Fazit
- Teile Inhalte danach auf, was jede Ansicht wirklich braucht: Listen wollen Metadaten, ein Beitrag will seinen Text. Lass die Übersicht nicht für 19 Artikel zahlen.
- Ein dynamisches
import()hinter einem typisiertenloadContent()ist alles, was nötig ist, um jedem Beitrag einen eigenen Chunk zu geben. - Lazy Loading und SSG stehen nicht im Widerspruch.
createAsynclöst auf dem Server auf und serialisiert in die Hydration-Payload, sodass das prerenderte HTML vollständig bleibt und der Fallback nie ausgeliefert wird. - Die verbleibende Naht — die Client-Navigation — ist UX, nicht SEO: ein an
readingTimeskaliertes Skelett und ein absichtsbasiertes Vorladen lassen sie verschwinden.