Ліниве завантаження контенту блогу в SolidStart без втрати SSG
Твій блог, найпевніше, віддає тіло кожного поста в одному бандлі — мій розрісся до 2,88 MB, і його качали навіть на сторінці-списку, де потрібні лише заголовки. Ось як я виніс прозу кожного поста в лінивий чанк, що вантажиться на вимогу, зберігши повний серверний рендер кожної сторінки для SEO.
Тримати контент у типізованому коді зручно — доки купа не розростеться настільки, що роздуває те, що качає кожен відвідувач. Ось як я розбив блог на 2,88 MB на чанки-по-посту в 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 MB вантажилися на кожній сторінці блогу — включно зі списком, де рендериться лише перелік заголовків і описів. Кожен відвідувач качав прозу всіх 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 KB серіалізованого скрипта гідратації.
Скелетон — це спінер. Сервер ніколи не віддає спінер: він тримає відповідь, доки дані готові, і віддає завершену сторінку. Краулери отримують завершену сторінку.
Два шляхи, і async лише в одному
Розбиття не послаблює SSG; воно додає шар code-splitting поверх нього. Є два різні шляхи, і лінивий чанк важить лише для одного:
| Хто | Що отримує | Content-чанк? |
|---|---|---|
| Google / прямий захід / F5 | Повний пререндер-HTML, гідратація з серіалізованого payload | Не качається |
| Відвідувач, що клікає всередині застосунку (SPA-навігація) | Роут рендериться на клієнті, createAsync запускає import() | Качається на вимогу |
За async платить лише той, хто вже всередині завантаженого застосунку й переходить між сторінками — тобто саме той, хто виграє від бандла на 108 KB.
Робимо 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 KB замість 2,88 MB. Відкрий пост напряму — отримаєш повністю серверно відрендерений HTML, і content-чанк узагалі не запитується. Перейди в нього всередині застосунку — його тіло підвантажиться, зазвичай уже прелоаднуте.
Висновки
- Розбивай контент за тим, що реально потрібно кожному в'ю: спискам — метадані, посту — його тіло. Не змушуй список платити за 19 статей.
- Динамічний
import()за типізованимloadContent()— це все, що треба, щоб дати кожному посту власний чанк. - Ліниве завантаження і SSG не конфліктують.
createAsyncрезолвиться на сервері й серіалізується в payload гідратації, тож пререндер-HTML лишається повним, а fallback ніколи в нього не потрапляє. - Залишковий шов — клієнтська навігація — це UX, а не SEO: скелетон розміром під
readingTimeі прелоад за наміром прибирають його.