Skip to main content
Înapoi la blog
SolidJSSolidStartPerformanceSSGCode Splitting

Î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.

Publicat 5 august 20268 min de citit

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 kB

Acei 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:

posts/<slug>.ts
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:

posts/<slug>.content.ts
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:             none

Zero 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:

CineCe primeșteChunk de conținut?
Google / vizită directă / F5HTML prerandat complet, se hidratează din payload-ul serializatNu 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 unui loadContent() tipizat e tot ce trebuie ca să dai fiecărui articol propriul chunk.
  • Încărcarea leneșă și SSG nu se bat cap în cap. createAsync se 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ă.