Skip to main content
Назад до блогу
SolidJSSolidStartPerformanceSSGCode Splitting

Ліниве завантаження контенту блогу в SolidStart без втрати SSG

Твій блог, найпевніше, віддає тіло кожного поста в одному бандлі — мій розрісся до 2,88 MB, і його качали навіть на сторінці-списку, де потрібні лише заголовки. Ось як я виніс прозу кожного поста в лінивий чанк, що вантажиться на вимогу, зберігши повний серверний рендер кожної сторінки для SEO.

Опубліковано 5 серпня 2026 р.8 хв читання

Тримати контент у типізованому коді зручно — доки купа не розростеться настільки, що роздуває те, що качає кожен відвідувач. Ось як я розбив блог на 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 — тримає лише метадані плюс функцію, що імпортує важку половину:

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"),
};

Важка половина — <slug>.content.ts — містить прозу, і ніщо не імпортує її статично:

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

Тип 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 і прелоад за наміром прибирають його.