Skip to main content
Tilbage til bloggen
SolidJSSolidStartPerformanceSSGCode Splitting

Lazy-load blogindhold i SolidStart uden at miste SSG

Din blog leverer sandsynligvis hvert indlægs brødtekst i ét enkelt bundle — mit var vokset til 2,88 MB og blev hentet selv på indekssiden, hvor du kun har brug for titlerne. Sådan delte jeg hvert indlægs tekst op i en lazy-chunk, der indlæses efter behov, mens hver side forbliver fuldt serverrenderet for SEO.

Udgivet 5. august 20268 min læsning

At holde indhold i typet kode er bekvemt — indtil bunken bliver stor nok til at oppuste det, hver besøgende henter. Sådan delte jeg en blog på 2,88 MB op i chunks per indlæg i SolidStart, uden at ofre en eneste serverrenderet side.

Én chunk til at herske over dem alle

Mit blogregister gjorde det oplagte: importere hvert indlæg statisk, lægge dem i et array, eksportere et par hjælpefunktioner.

import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these

const allPosts: BlogPost[] = [postA, postB, /* … */];

Hver BlogPost bar hele sin brødtekst — en ContentBlock[] — for alle 13 sprog. Statiske imports betyder, at bundleren trækker det hele ind i én chunk. Resultatet:

blog-C81oI990.js   2,880 kB │ gzip: 942 kB

De 2,88 MB blev leveret på hver blogside — inklusive indekset, hvor man kun renderer en liste med titler og beskrivelser. Hver besøgende hentede teksten fra alle 19 indlæg, på 13 sprog, for at læse ét.

Opdelingen: metadata i bundlet, tekst ved siden af

Løsningen er at adskille det, listen har brug for (billigt), fra det, et enkelt indlæg har brug for (dyrt). Hver indlægsfil blev til to.

Den lette halvdel — <slug>.ts — beholder kun metadata plus en funktion, der importerer den tunge halvdel:

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"),
};

Den tunge halvdel — <slug>.content.ts — indeholder teksten, og intet importerer den statisk:

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
};

Typen BlogMeta er kontrakten, der 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(), giver bundleren hvert indlægs brødtekst sin egen chunk, der kun hentes, når nogen kalder den.

Registret: synkrone resuméer, asynkront indhold

Registret importerer nu kun de lette halvdele og udstiller to slags adgang — 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 kalder getPostSummary/getAllPosts og rører aldrig en brødtekst. Kun indlægssiden rækker ud efter getPostContent.

Siden: SEO fra resuméet, brødtekst fra createAsync

Indlægsruten renderer i to hastigheder. Headeren, titlen, tags og hvert <meta>-/JSON-LD-tag kommer fra resuméet — synkrone, altid der. Brødteksten kommer fra createAsync, der indlæser 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 det stadig SSG?

Her er spørgsmålet, der stopper folk: der er jo en Suspense-fallback — indekserer Google så ikke skelettet i stedet for mit indhold?

Nej. Og grunden er værd at forstå, for det er hele den bærende bjælke.

Suspense viser sin fallback kun, mens det asynkrone afventer — og afventende sker kun der, hvor dataene endnu ikke er klar. Under prerendering er det aldrig tilfældet:

  • På serveren afventer SolidStart ressourcen, før den renderer. Når HTML'en produceres, er content() allerede løst, så fallback-grenen tages aldrig. Den statiske fil indeholder hele artiklen.
  • Derefter serialiserer SolidStart den løste værdi til hydration-payloaden. Ved et direkte besøg genoptager klienten ressourcen direkte fra den payload — den kører ikke getPostContent igen og henter derfor ikke engang indholds-chunken.

Du kan bevise det mod bygget. Grep et prerenderet indlæg:

skeleton markers (aria-busy, animate-pulse, "Loading"):  0
article code blocks (<pre>/<code>):                     11
reference to the .content chunk in the HTML:             none

Nul skelet. Fuld brødtekst. Indholds-chunken er ikke engang linket — teksten rejser i DOM'en og i det serialiserede hydration-script på cirka 20 KB.

Skelettet er en spinner. En server leverer aldrig en spinner — den holder på svaret, indtil dataene er klar, og leverer så den færdige side. Crawlere får den færdige side.

To veje, og kun én er asynkron

Opdelingen svækker ikke SSG; den lægger et code-splitting-lag ovenpå. Der er to forskellige veje, og den lazy chunk betyder kun noget for den ene:

HvemHvad de fårIndholds-chunk?
Google / direkte besøg / F5Fuldt prerenderet HTML, hydreres fra den serialiserede payloadHentes ikke
En besøgende, der klikker rundt i appen (SPA-navigation)Ruten renderer på klienten, createAsync udløser import()Hentes efter behov

Den asynkrone omkostning betales kun af den, der allerede er inde i den indlæste app og navigerer mellem sider — altså netop den, der får gavn af bundlet på 108 KB.

At gøre det asynkrone usynligt

For den ene SPA-navigationsvej fjerner to greb sømmen.

Skalér skelettet til indlægget. Når skelettet vises, er teksten ikke indlæst (det er hele pointen), så det eneste længdesignal, du har synkront, er readingTime. Rendér én afsnitsgruppe per minut, så et kort indlæg reserverer lidt og et langt mere — footeren holder op med at hoppe:

<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
  {() => <ParagraphGroup />}
</For>

Forudindlæs ved hensigt. Varm chunken op i det øjeblik, markøren eller tastaturet lander på et kort, så klikket møder en import i luften (eller i cachen) i stedet for en kold:

const preload = () => preloadPostContent(props.post.slug);

<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>

En cache med ét promise per slug får forudindlæsningen og det efterfølgende klik til at dele samme import(), så chunken aldrig hentes to gange. Med forudindlæsning bliver skelettet et sikkerhedsnet for det sjældne cache-miss (langsomt net, tryk uden hover) i stedet for normaltilfældet.

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)

Indekset og hver side, der ikke er et indlæg, leverer nu 108 KB i stedet for 2,88 MB. Åbn et indlæg direkte, og du får fuldt serverrenderet HTML, uden at indholds-chunken nogensinde efterspørges. Naviger til det inde i appen, og dens brødtekst indlæses — som regel allerede forudindlæst.

Opsummering

  • Del indhold op efter, hvad hver visning faktisk har brug for: lister vil have metadata, et indlæg vil have sin brødtekst. Lad ikke indekset betale for 19 artikler.
  • En dynamisk import() bag et typet loadContent() er alt, hvad der skal til for at give hvert indlæg sin egen chunk.
  • Lazy loading og SSG er ikke i modstrid. createAsync løses på serveren og serialiseres til hydration-payloaden, så den prerenderede HTML forbliver komplet, og fallbacken leveres aldrig.
  • Sømmen, der er tilbage — klientnavigationen — er UX, ikke SEO: et skelet skaleret til readingTime og en hensigtsbaseret forudindlæsning får den til at forsvinde.