Lazy-last blogginnhold i SolidStart uten å miste SSG
Bloggen din leverer sannsynligvis hvert innleggs brødtekst i én enkelt bundle — min hadde vokst til 2,88 MB og ble lastet ned selv på indekssiden, der du bare trenger titlene. Slik delte jeg hvert innleggs tekst i en lazy-chunk som lastes ved behov, mens hver side forblir fullstendig serverrendret for SEO.
Å holde innhold i typet kode er praktisk — helt til haugen blir stor nok til å blåse opp det hver besøkende laster ned. Slik delte jeg en blogg på 2,88 MB i chunks per innlegg i SolidStart, uten å ofre en eneste serverrendret side.
Én chunk til å herske over dem alle
Bloggregisteret mitt gjorde det åpenbare: importere hvert innlegg statisk, legge dem i et array, eksportere noen hjelpefunksjoner.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Hver BlogPost bar hele brødteksten sin — en ContentBlock[] — for alle 13 språk. Statiske importer gjør at bundleren drar alt dette inn i én chunk. Resultatet:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBDisse 2,88 MB ble levert på hver bloggside — inkludert indeksen, der man bare rendrer en liste med titler og beskrivelser. Hver besøkende lastet ned teksten fra alle 19 innleggene, på 13 språk, for å lese ett.
Oppdelingen: metadata i bundlet, tekst ved siden av
Løsningen er å skille det listen trenger (billig) fra det et enkelt innlegg trenger (dyrt). Hver innleggsfil ble to.
Den lette halvdelen — <slug>.ts — beholder bare metadata pluss en funksjon som importerer den tunge halvdelen:
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"),
};Den tunge halvdelen — <slug>.content.ts — inneholder teksten, og ingenting importerer den statisk:
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
};Typen BlogMeta er kontrakten som limer dem sammen:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Fordi loadContent er en dynamisk import(), gir bundleren hvert innleggs brødtekst sin egen chunk, som bare lastes når noen kaller den.
Registeret: synkrone sammendrag, asynkront innhold
Registeret importerer nå bare de lette halvdelene og tilbyr to typer tilgang — synkron metadata, asynkron brødtekst:
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;
}Indekssiden og kortene kaller getPostSummary/getAllPosts og rører aldri en brødtekst. Bare innleggssiden strekker seg etter getPostContent.
Siden: SEO fra sammendraget, brødtekst fra createAsync
Innleggsruten rendrer i to hastigheter. Toppteksten, tittelen, taggene og hver <meta>-/JSON-LD-tagg kommer fra sammendraget — synkrone, alltid der. Brødteksten kommer fra createAsync, som laster chunken:
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>
);Det alle misforstår: er dette fortsatt SSG?
Her er spørsmålet som stopper folk: det finnes jo en Suspense-fallback — indekserer ikke Google da skjelettet i stedet for innholdet mitt?
Nei. Og grunnen er verdt å forstå, for det er hele den bærende bjelken.
Suspense viser fallbacken sin bare mens det asynkrone venter — og venting skjer bare der dataene ennå ikke er klare. Under prerendering er det aldri tilfelle:
- På serveren venter SolidStart på ressursen før den rendrer. Når HTML-en produseres, er
content()allerede løst, så fallback-grenen tas aldri. Den statiske filen inneholder hele artikkelen. - Deretter serialiserer SolidStart den løste verdien til hydration-payloaden. Ved et direkte besøk gjenopptar klienten ressursen rett fra den payloaden — den kjører ikke
getPostContentpå nytt, og laster derfor ikke engang innholds-chunken.
Du kan bevise det mot bygget. Grep et prerendret innlegg:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneNull skjelett. Full brødtekst. Innholds-chunken er ikke engang lenket — teksten reiser i DOM-en og i det serialiserte hydration-skriptet på rundt 20 KB.
Skjelettet er en spinner. En server leverer aldri en spinner — den holder på svaret til dataene er klare, og leverer så den ferdige siden. Crawlere får den ferdige siden.
To veier, og bare én er asynkron
Oppdelingen svekker ikke SSG; den legger et code-splitting-lag oppå. Det finnes to ulike veier, og den late chunken betyr noe bare for den ene:
| Hvem | Hva de får | Innholds-chunk? |
|---|---|---|
| Google / direkte besøk / F5 | Fullstendig prerendret HTML, hydreres fra den serialiserte payloaden | Lastes ikke |
| En besøkende som klikker rundt i appen (SPA-navigasjon) | Ruten rendrer på klienten, createAsync utløser import() | Lastes ved behov |
Den asynkrone kostnaden betales bare av den som allerede er inne i den lastede appen og navigerer mellom sider — altså nettopp den som tjener på bundlet på 108 KB.
Å gjøre det asynkrone usynlig
For den ene SPA-navigasjonsveien fjerner to grep sømmen.
Skaler skjelettet til innlegget. Når skjelettet vises, er teksten ikke lastet (det er hele poenget), så det eneste lengdesignalet du har synkront, er readingTime. Rendr én avsnittsgruppe per minutt slik at et kort innlegg reserverer lite og et langt mer — bunnteksten slutter å hoppe:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Forhåndslast ved intensjon. Varm opp chunken i det øyeblikket pekeren eller tastaturet lander på et kort, slik at klikket møter en import i lufta (eller i cachen) i stedet for en kald:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>En cache med ett promise per slug gjør at forhåndslastingen og klikket som følger deler samme import(), så chunken lastes aldri to ganger. Med forhåndslasting blir skjelettet et sikkerhetsnett for det sjeldne cache-misset (tregt nett, trykk uten hover) i stedet for normaltilfellet.
Resultater
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)Indeksen og hver side som ikke er et innlegg, leverer nå 108 KB i stedet for 2,88 MB. Åpne et innlegg direkte, og du får fullstendig serverrendret HTML, uten at innholds-chunken noen gang etterspørres. Naviger til det inne i appen, og brødteksten lastes inn — som regel allerede forhåndslastet.
Oppsummering
- Del opp innhold etter hva hver visning faktisk trenger: lister vil ha metadata, et innlegg vil ha brødteksten sin. Ikke la indeksen betale for 19 artikler.
- En dynamisk
import()bak et typetloadContent()er alt som skal til for å gi hvert innlegg sin egen chunk. - Lazy loading og SSG er ikke i konflikt.
createAsyncløses på serveren og serialiseres til hydration-payloaden, så den prerendrete HTML-en forblir komplett og fallbacken leveres aldri. - Sømmen som gjenstår — klientnavigasjonen — er UX, ikke SEO: et skjelett skalert til
readingTimeog en intensjonsbasert forhåndslasting får den til å forsvinne.