Charger le contenu du blog à la demande dans SolidStart sans perdre le SSG
Ton blog livre sans doute le corps de chaque article dans un seul bundle — le mien avait grossi jusqu'à 2,88 Mo, téléchargé même sur la page d'index où tu n'as besoin que des titres. Voici comment j'ai découpé la prose de chaque article dans un chunk chargé à la demande, tout en gardant chaque page entièrement rendue côté serveur pour le SEO.
Garder le contenu dans du code typé est pratique — jusqu'à ce que la pile devienne assez grosse pour gonfler ce que chaque visiteur télécharge. Voici comment j'ai découpé un blog de 2,88 Mo en chunks par article dans SolidStart, sans sacrifier une seule page rendue côté serveur.
Un seul chunk pour les gouverner tous
Mon registre de blog faisait l'évidence : importer statiquement chaque article, les mettre dans un tableau, exporter quelques helpers.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Chaque BlogPost portait tout son corps — un ContentBlock[] — pour les 13 langues. Les imports statiques font que le bundler tire tout cela dans un seul chunk. Le résultat :
blog-C81oI990.js 2,880 kB │ gzip: 942 kBCes 2,88 Mo étaient livrés sur chaque page du blog — y compris l'index, où l'on ne rend qu'une liste de titres et de descriptions. Chaque visiteur téléchargeait la prose des 19 articles, en 13 langues, pour en lire un.
Le découpage : les métadonnées dans le bundle, la prose à côté
La solution : séparer ce dont la liste a besoin (peu coûteux) de ce dont un article a besoin (coûteux). Chaque fichier d'article est devenu deux fichiers.
La moitié légère — <slug>.ts — ne garde que les métadonnées, plus une fonction qui importe la moitié lourde :
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"),
};La moitié lourde — <slug>.content.ts — contient la prose, et rien ne l'importe statiquement :
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
};Le type BlogMeta est le contrat qui les relie :
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Comme loadContent est un import() dynamique, le bundler donne au corps de chaque article son propre chunk, qui n'est téléchargé que lorsque quelqu'un l'appelle.
Le registre : des résumés synchrones, du contenu asynchrone
Le registre n'importe plus que les moitiés légères et expose deux types d'accès — des métadonnées synchrones, un corps asynchrone :
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;
}La page d'index et les cartes appellent getPostSummary/getAllPosts et ne touchent jamais à un corps. Seule la page d'article fait appel à getPostContent.
La page : le SEO depuis le résumé, le corps depuis createAsync
La route d'article rend à deux vitesses. L'en-tête, le titre, les tags et chaque balise <meta>/JSON-LD viennent du résumé — synchrones, toujours présents. Le corps vient de createAsync, qui charge le chunk :
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>
);Ce que tout le monde comprend de travers : est-ce encore du SSG ?
Voici la question qui bloque les gens : il y a un fallback de Suspense — Google ne va-t-il donc pas indexer le squelette au lieu de mon contenu ?
Non. Et la raison vaut la peine d'être comprise, car c'est la poutre porteuse.
Suspense n'affiche son fallback que tant que l'asynchrone est en attente — et l'attente ne survient que là où les données ne sont pas encore prêtes. Pendant le prérendu, ce n'est jamais le cas :
- Sur le serveur, SolidStart attend la ressource avant de rendre. Au moment où le HTML est produit,
content()est déjà résolu, donc la branche du fallback n'est jamais prise. Le fichier statique contient l'article complet. - Ensuite, SolidStart sérialise la valeur résolue dans le payload d'hydratation. Lors d'une visite directe, le client reprend la ressource directement depuis ce payload — il ne réexécute pas
getPostContent, et ne télécharge donc même pas le chunk de contenu.
On peut le prouver sur le build. Fais un grep sur un article prérendu :
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneZéro squelette. Corps complet. Le chunk de contenu n'est même pas référencé — la prose voyage dans le DOM et dans le script d'hydratation sérialisé d'environ 20 Ko.
Le squelette est un spinner. Un serveur ne livre jamais un spinner : il retient la réponse jusqu'à ce que les données soient prêtes, puis livre la page finie. Les crawlers reçoivent la page finie.
Deux chemins, et un seul est asynchrone
Le découpage n'affaiblit pas le SSG ; il ajoute une couche de code-splitting par-dessus. Il y a deux chemins distincts, et le chunk paresseux ne compte que pour l'un :
| Qui | Ce qu'il reçoit | Chunk de contenu ? |
|---|---|---|
| Google / visite directe / F5 | HTML prérendu complet, hydraté depuis le payload sérialisé | Non téléchargé |
| Un visiteur qui navigue dans l'app (navigation SPA) | La route rend côté client, createAsync déclenche l'import() | Téléchargé à la demande |
Le coût asynchrone n'est payé que par celui qui est déjà dans l'app chargée et navigue entre les pages — c'est-à-dire précisément celui qui profite du bundle de 108 Ko.
Rendre l'asynchrone invisible
Pour ce seul chemin de navigation SPA, deux touches suppriment la couture.
Dimensionne le squelette à l'article. Au moment du squelette, la prose n'est pas chargée (c'est justement le principe), donc le seul signal de longueur disponible de façon synchrone est readingTime. Rends un groupe de paragraphes par minute pour qu'un article court réserve peu et un long davantage — le pied de page cesse de sauter :
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Précharge à l'intention. Chauffe le chunk dès que le pointeur ou le clavier arrive sur une carte, pour que le clic rencontre un import en vol (ou en cache) plutôt qu'un import froid :
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>Un cache d'une promesse par slug fait que le préchargement et le clic qui suit partagent le même import(), si bien que le chunk n'est jamais téléchargé deux fois. Avec le préchargement, le squelette devient un filet de sécurité pour le rare cache-miss (réseau lent, tap sans survol) plutôt que le cas courant.
Résultats
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)L'index et chaque page hors article livrent désormais 108 Ko au lieu de 2,88 Mo. Ouvre un article directement et tu obtiens un HTML entièrement rendu côté serveur, sans que le chunk de contenu soit jamais demandé. Navigue vers lui dans l'app et son corps se charge — le plus souvent déjà préchargé.
À retenir
- Découpe le contenu selon ce dont chaque vue a réellement besoin : les listes veulent des métadonnées, un article veut son corps. Ne fais pas payer 19 articles à l'index.
- Un
import()dynamique derrière unloadContent()typé suffit à donner à chaque article son propre chunk. - Le chargement paresseux et le SSG ne s'opposent pas.
createAsyncse résout sur le serveur et se sérialise dans le payload d'hydratation, de sorte que le HTML prérendu reste complet et que le fallback n'est jamais livré. - La couture qui reste — la navigation côté client — relève de l'UX, pas du SEO : un squelette dimensionné à
readingTimeet un préchargement à l'intention la font disparaître.