Carga diferida del contenido del blog en SolidStart sin perder el SSG
Tu blog probablemente entrega el cuerpo de cada artículo en un único bundle — el mío había crecido hasta 2,88 MB, y se descargaba incluso en la página de índice, donde solo necesitas los títulos. Así dividí la prosa de cada artículo en un chunk diferido que carga bajo demanda, manteniendo cada página renderizada por completo en el servidor para el SEO.
Mantener el contenido en código tipado es cómodo — hasta que el montón crece lo suficiente como para inflar lo que descarga cada visitante. Así dividí un blog de 2,88 MB en chunks por artículo en SolidStart, sin renunciar a una sola página renderizada en el servidor.
Un chunk para gobernarlos a todos
Mi registro del blog hacía lo obvio: importar estáticamente cada artículo, meterlos en un array, exportar un par de helpers.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Cada BlogPost llevaba su cuerpo completo — un ContentBlock[] — para los 13 idiomas. Los imports estáticos hacen que el bundler arrastre todo eso a un solo chunk. El resultado:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBEsos 2,88 MB se entregaban en cada página del blog — incluido el índice, donde solo se renderiza una lista de títulos y descripciones. Cada visitante descargaba la prosa de los 19 artículos, en 13 idiomas, para leer uno.
La división: metadatos en el bundle, prosa aparte
El arreglo es separar lo que necesita el listado (barato) de lo que necesita un solo artículo (caro). Cada archivo de artículo pasó a ser dos.
La mitad ligera — <slug>.ts — solo guarda metadatos más una función que importa la mitad pesada:
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 mitad pesada — <slug>.content.ts — contiene la prosa, y nada la importa de forma estática:
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
};El tipo BlogMeta es el contrato que las une:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Como loadContent es un import() dinámico, el bundler le da al cuerpo de cada artículo su propio chunk, que solo se descarga cuando alguien lo invoca.
El registro: resúmenes síncronos, contenido asíncrono
El registro ahora solo importa las mitades ligeras y expone dos tipos de acceso — metadatos síncronos, cuerpo asíncrono:
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 página de índice y las tarjetas llaman a getPostSummary/getAllPosts y nunca tocan un cuerpo. Solo la página del artículo recurre a getPostContent.
La página: SEO desde el resumen, cuerpo desde createAsync
La ruta del artículo renderiza a dos velocidades. La cabecera, el título, las etiquetas y cada etiqueta <meta>/JSON-LD vienen del resumen — síncronos, siempre ahí. El cuerpo llega desde createAsync, que carga el 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>
);La parte que todos malinterpretan: ¿sigue siendo SSG?
Aquí está la pregunta que frena a la gente: hay un fallback de Suspense — ¿entonces Google no indexará el esqueleto en lugar de mi contenido?
No. Y vale la pena entender el motivo, porque es la viga maestra.
Suspense muestra su fallback solo mientras la operación asíncrona está pendiente — y pendiente solo ocurre donde los datos aún no están listos. Durante el prerenderizado, eso no pasa nunca:
- En el servidor, SolidStart espera el recurso antes de renderizar. Cuando se produce el HTML,
content()ya está resuelto, así que la rama del fallback nunca se toma. El archivo estático contiene el artículo completo. - Luego SolidStart serializa el valor resuelto en el payload de hidratación. En una visita directa, el cliente reanuda el recurso directamente desde ese payload — no vuelve a ejecutar
getPostContent, así que ni siquiera descarga el chunk de contenido.
Puedes comprobarlo sobre el build. Haz grep de un artículo prerenderizado:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneCero esqueleto. Cuerpo completo. El chunk de contenido ni siquiera está enlazado — la prosa viaja en el DOM y en el script de hidratación serializado de ~20 KB.
El esqueleto es un spinner. Un servidor nunca entrega un spinner: retiene la respuesta hasta que los datos están listos, y entonces entrega la página terminada. Los crawlers reciben la página terminada.
Dos caminos, y solo uno es asíncrono
Dividir no debilita el SSG; añade una capa de code-splitting encima. Hay dos caminos distintos, y el chunk diferido solo importa para uno:
| Quién | Qué recibe | ¿Chunk de contenido? |
|---|---|---|
| Google / visita directa / F5 | HTML prerenderizado completo, hidrata desde el payload serializado | No se descarga |
| Un visitante navegando por la app (navegación SPA) | La ruta renderiza en el cliente, createAsync dispara el import() | Se descarga bajo demanda |
El coste asíncrono solo lo paga quien ya está dentro de la app cargada, navegando entre páginas — es decir, justo quien se beneficia del bundle de 108 KB.
Hacer invisible lo asíncrono
Para ese único camino de navegación SPA, dos toques eliminan la costura.
Ajusta el esqueleto al artículo. Cuando aparece el esqueleto, la prosa no está cargada (esa es la idea), así que la única señal de longitud que tienes de forma síncrona es readingTime. Renderiza un grupo de párrafos por minuto para que un artículo corto reserve poco y uno largo reserve más — el pie de página deja de saltar:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Precarga según la intención. Calienta el chunk en el momento en que el puntero o el teclado aterrizan sobre una tarjeta, para que el clic se encuentre con un import en vuelo (o en caché) en lugar de uno frío:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>Una caché de una promesa por slug hace que la precarga y el clic que sigue compartan el mismo import(), así que el chunk nunca se descarga dos veces. Con la precarga, el esqueleto es un recurso de respaldo para el raro fallo de caché (red lenta, toque sin hover) en lugar del caso habitual.
Resultados
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)El índice y cada página que no sea de artículo ahora entregan 108 KB en lugar de 2,88 MB. Abre un artículo directamente y obtienes HTML renderizado por completo en el servidor, sin que el chunk de contenido se solicite jamás. Navega hacia él dentro de la app y su cuerpo se carga — normalmente ya precargado.
Conclusiones
- Divide el contenido según lo que cada vista necesita de verdad: los listados quieren metadatos, un artículo quiere su cuerpo. No hagas que el índice pague por 19 artículos.
- Un
import()dinámico detrás de unloadContent()tipado es todo lo que hace falta para darle a cada artículo su propio chunk. - La carga diferida y el SSG no están en conflicto.
createAsyncse resuelve en el servidor y se serializa en el payload de hidratación, así que el HTML prerenderizado sigue completo y el fallback nunca se entrega. - La costura que queda — la navegación en el cliente — es UX, no SEO: un esqueleto ajustado a
readingTimey una precarga según la intención la hacen desaparecer.