Ленивая загрузка контента блога в SolidStart без потери SSG
Твой блог, скорее всего, отдаёт тело каждого поста в одном бандле — мой разросся до 2,88 МБ и качался даже на странице-списке, где нужны только заголовки. Вот как я вынес прозу каждого поста в ленивый чанк, который грузится по требованию, сохранив полный серверный рендер каждой страницы для SEO.
Держать контент в типизированном коде удобно — пока куча не разрастётся настолько, что раздувает то, что качает каждый посетитель. Вот как я разбил блог на 2,88 МБ на чанки-по-посту в SolidStart, не пожертвовав ни одной серверно отрендеренной страницей.
Один чанк, чтобы править всеми
Мой реестр блога делал очевидное: статически импортировал каждый пост, складывал их в массив, экспортировал пару хелперов.
import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these
const allPosts: BlogPost[] = [postA, postB, /* … */];Каждый BlogPost нёс всё своё тело — ContentBlock[] — для всех 13 локалей. Статические импорты означают, что бандлер затягивает всё это в один чанк. Результат:
blog-C81oI990.js 2,880 kB │ gzip: 942 kBЭти 2,88 МБ отдавались на каждой странице блога — включая список, где рендерится лишь перечень заголовков и описаний. Каждый посетитель качал прозу всех 19 постов на 13 языках, чтобы прочитать один.
Разбиение: метаданные в бандле, проза — отдельно
Решение — отделить то, что нужно списку (дёшево), от того, что нужно отдельному посту (дорого). Каждый файл поста стал двумя.
Лёгкая половина — <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"),
};Тяжёлая половина — <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
};Тип BlogMeta — это контракт, который их склеивает:
export interface BlogMeta {
slug: string;
date: string;
readingTime: number;
tags: string[];
translations: Record<Language, { title: string; description: string }>;
loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}Поскольку loadContent — это динамический import(), бандлер даёт телу каждого поста собственный чанк, который загружается только когда его кто-то вызовет.
Реестр: синхронные summary, асинхронный контент
Теперь реестр импортирует только лёгкие половины и даёт два вида доступа — синхронные метаданные и асинхронное тело:
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;
}Страница-список и карточки вызывают getPostSummary/getAllPosts и никогда не трогают тело. Только страница поста обращается к getPostContent.
Страница: SEO из summary, тело — из createAsync
Роут поста рендерится на двух скоростях. Хедер, заголовок, теги и каждый тег <meta>/JSON-LD берутся из summary — синхронно, они есть всегда. Тело приходит из createAsync, который грузит чанк:
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>
);Место, где все ошибаются: это всё ещё SSG?
Вот вопрос, который останавливает людей: там же есть fallback у Suspense — так разве Google не проиндексирует скелетон вместо моего контента?
Нет. И причину стоит понять, потому что это вся несущая балка.
Suspense показывает свой fallback только пока async в состоянии ожидания — а ожидание возникает лишь там, где данных ещё нет. При пререндере это не так никогда:
- На сервере SolidStart дожидается ресурса перед рендером. К моменту, когда HTML сгенерирован,
content()уже зарезолвлен, так что ветка fallback не срабатывает никогда. Статический файл содержит полную статью. - Затем SolidStart сериализует зарезолвленное значение в payload гидратации. При прямом заходе клиент восстанавливает ресурс прямо из этого payload — он не запускает
getPostContentзаново и потому даже не качает content-чанк.
Это можно доказать на собранном выводе. Грепни один пререндер-пост:
skeleton markers (aria-busy, animate-pulse, "Loading"): 0
article code blocks (<pre>/<code>): 11
reference to the .content chunk in the HTML: noneНоль скелетона. Полное тело. Content-чанк даже не залинкован — проза едет и в DOM, и в ~20 КБ сериализованного скрипта гидратации.
Скелетон — это спиннер. Сервер никогда не отдаёт спиннер: он держит ответ, пока данные не готовы, и отдаёт завершённую страницу. Краулеры получают завершённую страницу.
Два пути, и async лишь в одном
Разбиение не ослабляет SSG; оно добавляет слой code-splitting поверх него. Есть два разных пути, и ленивый чанк важен лишь для одного:
| Кто | Что получает | Content-чанк? |
|---|---|---|
| Google / прямой заход / F5 | Полный пререндер-HTML, гидратация из сериализованного payload | Не качается |
| Посетитель, кликающий внутри приложения (SPA-навигация) | Роут рендерится на клиенте, createAsync запускает import() | Качается по требованию |
За async платит лишь тот, кто уже внутри загруженного приложения и переходит между страницами — то есть именно тот, кто выигрывает от бандла на 108 КБ.
Делаем async незаметным
Для этого единственного SPA-навигационного пути шов убирают два штриха.
Масштабируй скелетон под пост. В момент показа скелетона проза ещё не загружена (в этом и суть), так что единственный сигнал длины, доступный синхронно, — это readingTime. Рендери одну группу-абзац на минуту, чтобы короткий пост резервировал мало, а длинный — больше; футер перестаёт прыгать:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>Прелоад по намерению. Прогрей чанк в тот же миг, когда курсор или клавиатура попадают на карточку, чтобы клик встретил импорт в полёте (или из кеша), а не холодный:
const preload = () => preloadPostContent(props.post.slug);
<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>Кеш «один промис на slug» заставляет прелоад и следующий клик делить один и тот же import(), так что чанк никогда не качается дважды. С прелоадом скелетон становится запасным вариантом для редкого кеш-мисса (медленная сеть, тап без hover), а не обычным случаем.
Результаты
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)Список и каждая не-постовая страница теперь отдают 108 КБ вместо 2,88 МБ. Открой пост напрямую — получишь полностью серверно отрендеренный HTML, и content-чанк вообще не запрашивается. Перейди в него внутри приложения — его тело подгрузится, обычно уже прелоаднутое.
Выводы
- Разбивай контент по тому, что реально нужно каждому вью: спискам — метаданные, посту — его тело. Не заставляй список платить за 19 статей.
- Динамический
import()за типизированнымloadContent()— это всё, что нужно, чтобы дать каждому посту собственный чанк. - Ленивая загрузка и SSG не конфликтуют.
createAsyncрезолвится на сервере и сериализуется в payload гидратации, так что пререндер-HTML остаётся полным, а fallback никогда в него не попадает. - Оставшийся шов — клиентская навигация — это UX, а не SEO: скелетон размером под
readingTimeи прелоад по намерению убирают его.