Încărcarea leneșă a conținutului blogului în SolidStart fără a pierde SSG
Blogul tău livrează probabil corpul fiecărui articol într-un singur bundle — al meu crescuse la 2,88 MB și se descărca chiar și pe pagina de index, unde ai nevoie doar de titluri. Iată cum am împărțit proza fiecărui articol într-un chunk leneș care se încarcă la cerere, în timp ce fiecare pagină rămâne complet randată pe server pentru SEO.
A ține conținutul în cod tipizat e convenabil — până când grămada crește suficient cât să umfle ceea ce descarcă fiecare vizitator. Iată cum am împărțit un blog de 2,88 MB în chunk-uri per articol în SolidStart, fără a renunța la nici măcar o pagină randată pe server.
Un chunk ca să le stăpânească pe toate
Registrul blogului meu făcea lucrul evident: importa static fiecare articol, le punea într-un array, exporta câteva funcții ajutătoare.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Fiecare BlogPost își purta întregul corp — un ContentBlock[] — pentru toate cele 13 limbi. Importurile statice fac ca bundlerul să tragă tot în același chunk. Rezultatul:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBAcei 2,88 MB se livrau pe fiecare pagină a blogului — inclusiv pe index, unde se randează doar o listă de titluri și descrieri. Fiecare vizitator descărca proza tuturor celor 19 articole, în 13 limbi, ca să citească unul.
Împărțirea: metadatele în bundle, proza separat
Soluția e să separi ce are nevoie lista (ieftin) de ce are nevoie un singur articol (scump). Fiecare fișier de articol a devenit două.
Jumătatea ușoară — <slug>.ts — păstrează doar metadatele plus o funcție care importă jumătatea grea:
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"),
};Jumătatea grea — <slug>.content.ts — conține proza, și nimic nu o importă static:
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
};Tipul BlogMeta este contractul care le lipește:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Fiindcă loadContent este un import() dinamic, bundlerul dă corpului fiecărui articol propriul chunk, care se încarcă doar când îl apelează cineva.
Registrul: rezumate sincrone, conținut asincron
Acum registrul importă doar jumătățile ușoare și expune două feluri de acces — metadate sincrone, corp asincron:
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;
}Pagina de index și cardurile apelează getPostSummary/getAllPosts și nu ating niciodată un corp. Doar pagina de articol recurge la getPostContent.
Pagina: SEO din rezumat, corpul din createAsync
Ruta articolului randează în două viteze. Antetul, titlul, tagurile și fiecare tag <meta>/JSON-LD vin din rezumat — sincrone, mereu prezente. Corpul vine din createAsync, care încarcă chunk-ul:
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>
);Partea pe care toți o înțeleg greșit: mai e SSG?
Iată întrebarea care îi oprește pe oameni: există un fallback de Suspense — atunci nu cumva Google va indexa scheletul în loc de conținutul meu?
Nu. Și motivul merită înțeles, fiindcă e toată grinda de rezistență.
Suspense își arată fallback-ul doar cât timp asincronul e în așteptare — iar așteptarea apare doar acolo unde datele nu sunt încă gata. În timpul prerandării, asta nu se întâmplă niciodată:
- Pe server, SolidStart așteaptă resursa înainte să randeze. Când se produce HTML-ul,
content()e deja rezolvat, așa că ramura de fallback nu e luată niciodată. Fișierul static conține articolul întreg. - Apoi SolidStart serializează valoarea rezolvată în payload-ul de hidratare. La o vizită directă, clientul reia resursa direct din acel payload — nu execută din nou
getPostContent, așa că nici măcar nu descarcă chunk-ul de conținut.
Poți dovedi asta pe build. Dă grep pe un articol prerandat:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneZero schelet. Corp complet. Chunk-ul de conținut nici măcar nu e referențiat — proza călătorește în DOM și în scriptul de hidratare serializat de ~20 KB.
Scheletul e un spinner. Un server nu livrează niciodată un spinner — ține răspunsul până când datele sunt gata, apoi livrează pagina finită. Crawlerele primesc pagina finită.
Două căi, și doar una e asincronă
Împărțirea nu slăbește SSG; adaugă un strat de code-splitting deasupra. Există două căi distincte, iar chunk-ul leneș contează doar pentru una:
| Cine | Ce primește | Chunk de conținut? |
|---|---|---|
| Google / vizită directă / F5 | HTML prerandat complet, se hidratează din payload-ul serializat | Nu se descarcă |
| Un vizitator care navighează prin aplicație (navigare SPA) | Ruta randează pe client, createAsync declanșează import() | Se descarcă la cerere |
Costul asincron e plătit doar de cine e deja în aplicația încărcată și navighează între pagini — adică exact cine beneficiază de bundle-ul de 108 KB.
Facem asincronul invizibil
Pentru acea unică cale de navigare SPA, două retușuri elimină cusătura.
Dimensionează scheletul după articol. În momentul scheletului, proza nu e încărcată (asta e toată ideea), așa că singurul semnal de lungime pe care îl ai sincron e readingTime. Randează un grup de paragrafe pe minut, ca un articol scurt să rezerve puțin și unul lung mai mult — subsolul se oprește din sărit:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Preîncarcă la intenție. Încălzește chunk-ul chiar în clipa în care cursorul sau tastatura ajung pe un card, ca clicul să întâlnească un import în zbor (sau din cache) în loc de unul rece:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>Un cache cu o promisiune per slug face ca preîncărcarea și clicul care urmează să împartă același import(), așa că chunk-ul nu se descarcă niciodată de două ori. Cu preîncărcare, scheletul devine o plasă de siguranță pentru rarul cache-miss (rețea lentă, atingere fără hover), nu cazul obișnuit.
Rezultate
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)Indexul și fiecare pagină care nu e articol livrează acum 108 KB în loc de 2,88 MB. Deschide un articol direct și primești HTML randat complet pe server, fără ca chunk-ul de conținut să fie vreodată cerut. Navighează spre el în aplicație și corpul lui se încarcă — de obicei deja preîncărcat.
Concluzii
- Împarte conținutul după ce are nevoie cu adevărat fiecare vizualizare: listele vor metadate, un articol vrea corpul lui. Nu pune indexul să plătească pentru 19 articole.
- Un
import()dinamic în spatele unuiloadContent()tipizat e tot ce trebuie ca să dai fiecărui articol propriul chunk. - Încărcarea leneșă și SSG nu se bat cap în cap.
createAsyncse rezolvă pe server și se serializează în payload-ul de hidratare, așa că HTML-ul prerandat rămâne complet, iar fallback-ul nu e livrat niciodată. - Cusătura rămasă — navigarea pe client — ține de UX, nu de SEO: un schelet dimensionat după
readingTimeși o preîncărcare la intenție o fac să dispară.