Skip to main content
Tillbaka till bloggen
SolidJSSolidStartPerformanceSSGCode Splitting

Ladda blogginnehåll lazy i SolidStart utan att förlora SSG

Din blogg levererar förmodligen varje inläggs brödtext i ett enda bundle — mitt hade vuxit till 2,88 MB och laddades även på indexsidan, där du bara behöver titlarna. Så här delade jag upp varje inläggs text i en lazy-chunk som laddas vid behov, medan varje sida förblir helt serverrenderad för SEO.

Publicerad 5 augusti 20268 min läsning

Att hålla innehåll i typad kod är bekvämt — tills högen blir stor nog att blåsa upp det varje besökare laddar ner. Så här delade jag upp en blogg på 2,88 MB i chunks per inlägg i SolidStart, utan att offra en enda serverrenderad sida.

En chunk att härska över dem alla

Mitt bloggregister gjorde det uppenbara: importera varje inlägg statiskt, lägga dem i en array, exportera några hjälpfunktioner.

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

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

Varje BlogPost bar hela sin brödtext — en ContentBlock[] — för alla 13 språk. Statiska importer gör att bundlaren drar in allt detta i en enda chunk. Resultatet:

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

De där 2,88 MB levererades på varje bloggsida — inklusive indexet, där man bara renderar en lista med titlar och beskrivningar. Varje besökare laddade ner texten från alla 19 inlägg, på 13 språk, för att läsa ett.

Uppdelningen: metadata i bundlet, text vid sidan om

Lösningen är att skilja det listan behöver (billigt) från det ett enskilt inlägg behöver (dyrt). Varje inläggsfil blev två.

Den lätta halvan — <slug>.ts — behåller bara metadata plus en funktion som importerar den tunga halvan:

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 tunga halvan — <slug>.content.ts — innehåller texten, och inget importerar den statiskt:

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 är kontraktet som limmar ihop dem:

export interface BlogMeta {
  slug: string;
  date: string;
  readingTime: number;
  tags: string[];
  translations: Record<Language, { title: string; description: string }>;
  loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}

Eftersom loadContent är en dynamisk import() ger bundlaren varje inläggs brödtext en egen chunk, som bara laddas när någon anropar den.

Registret: synkrona sammanfattningar, asynkront innehåll

Registret importerar nu bara de lätta halvorna och erbjuder två sorters åtkomst — synkron metadata, asynkron brödtext:

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

Indexsidan och korten anropar getPostSummary/getAllPosts och rör aldrig en brödtext. Bara inläggssidan sträcker sig efter getPostContent.

Sidan: SEO från sammanfattningen, brödtext från createAsync

Inläggsrutten renderar i två hastigheter. Huvudet, titeln, taggarna och varje <meta>-/JSON-LD-tagg kommer från sammanfattningen — synkrona, alltid där. Brödtexten kommer från createAsync, som laddar 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 alla missförstår: är det fortfarande SSG?

Här är frågan som stoppar folk: det finns ju en Suspense-fallback — indexerar då inte Google skelettet i stället för mitt innehåll?

Nej. Och skälet är värt att förstå, för det är hela den bärande bjälken.

Suspense visar sin fallback bara medan det asynkrona väntar — och väntande sker bara där datan ännu inte är redo. Under prerendering är det aldrig fallet:

  • På servern inväntar SolidStart resursen innan den renderar. När HTML:en skapas är content() redan löst, så fallback-grenen tas aldrig. Den statiska filen innehåller hela artikeln.
  • Sedan serialiserar SolidStart det lösta värdet till hydration-payloaden. Vid ett direktbesök återupptar klienten resursen direkt från den payloaden — den kör inte getPostContent igen, och laddar därför inte ens innehålls-chunken.

Du kan bevisa det mot bygget. Greppa ett prerenderat inlägg:

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

Noll skelett. Full brödtext. Innehålls-chunken är inte ens länkad — texten färdas i DOM:en och i det serialiserade hydration-skriptet på cirka 20 KB.

Skelettet är en spinner. En server levererar aldrig en spinner — den håller kvar svaret tills datan är redo och levererar sedan den färdiga sidan. Crawlers får den färdiga sidan.

Två vägar, och bara en är asynkron

Uppdelningen försvagar inte SSG; den lägger ett code-splitting-lager ovanpå. Det finns två skilda vägar, och den lata chunken spelar roll bara för den ena:

VemVad de fårInnehålls-chunk?
Google / direktbesök / F5Fullständig prerenderad HTML, hydreras från den serialiserade payloadenLaddas inte
En besökare som klickar runt i appen (SPA-navigering)Rutten renderar på klienten, createAsync utlöser import()Laddas vid behov

Den asynkrona kostnaden betalas bara av den som redan är inne i den laddade appen och navigerar mellan sidor — alltså exakt den som tjänar på bundlet på 108 KB.

Att göra det asynkrona osynligt

För den enda SPA-navigeringsvägen tar två handgrepp bort skarven.

Anpassa skelettet till inlägget. När skelettet visas är texten inte laddad (det är hela poängen), så den enda längdsignal du har synkront är readingTime. Rendera en styckegrupp per minut så att ett kort inlägg reserverar lite och ett långt mer — sidfoten slutar hoppa:

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

Förladda vid avsikt. Värm chunken i samma stund som pekaren eller tangentbordet landar på ett kort, så att klicket möter en import i luften (eller i cachen) i stället för en kall:

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

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

En cache med ett promise per slug gör att förladdningen och klicket som följer delar samma import(), så chunken laddas aldrig två gånger. Med förladdning blir skelettet ett skyddsnät för den sällsynta cache-missen (långsamt nät, tryck utan hover) i stället för normalfallet.

Resultat

                    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)

Indexet och varje sida som inte är ett inlägg levererar nu 108 KB i stället för 2,88 MB. Öppna ett inlägg direkt och du får helt serverrenderad HTML, utan att innehålls-chunken någonsin begärs. Navigera till det inne i appen och dess brödtext laddas in — oftast redan förladdad.

Sammanfattning

  • Dela upp innehåll efter vad varje vy faktiskt behöver: listor vill ha metadata, ett inlägg vill ha sin brödtext. Låt inte indexet betala för 19 artiklar.
  • En dynamisk import() bakom ett typat loadContent() är allt som krävs för att ge varje inlägg en egen chunk.
  • Lazy loading och SSG står inte i konflikt. createAsync löses på servern och serialiseras till hydration-payloaden, så den prerenderade HTML:en förblir komplett och fallbacken levereras aldrig.
  • Skarven som återstår — klientnavigeringen — är UX, inte SEO: ett skelett anpassat till readingTime och en avsiktsbaserad förladdning får den att försvinna.