Prefetching на сервере

Server-side prefetching в TanStack Query — механизм предварительной загрузки данных на сервере до момента отправки HTML клиенту. Основная цель — исключить повторные запросы после гидратации приложения и обеспечить мгновенное отображение готовых данных на клиентской стороне.

Подход особенно важен в следующих сценариях:

  • SSR-приложения на React;
  • Next.js Pages Router;
  • Next.js App Router;
  • Remix;
  • SEO-ориентированные страницы;
  • критически важные данные первого экрана;
  • серверный рендеринг каталогов, карточек товаров, профилей;
  • оптимизация Core Web Vitals.

Без prefetching клиент обычно выполняет следующую последовательность:

  1. Сервер отправляет HTML.
  2. Браузер загружает JavaScript.
  3. React гидратирует приложение.
  4. TanStack Query запускает запросы.
  5. Интерфейс получает данные.

Это приводит к:

  • дополнительным задержкам;
  • состояниям loading после SSR;
  • скачкам интерфейса;
  • ухудшению SEO;
  • дублированию запросов.

Server prefetching переносит загрузку данных на сервер:

  1. Сервер создаёт QueryClient.
  2. Выполняются prefetch-запросы.
  3. Кеш сериализуется.
  4. HTML отправляется вместе с dehydrated state.
  5. Клиент гидратирует кеш.
  6. useQuery мгновенно получает готовые данные.

Базовая схема SSR в TanStack Query

Архитектура server prefetching строится вокруг трёх ключевых API:

  • prefetchQuery
  • dehydrate
  • HydrationBoundary или Hydrate

Полный жизненный цикл выглядит так:

Server:
QueryClient
   ↓
prefetchQuery()
   ↓
cache filled
   ↓
dehydrate()
   ↓
serialized state

Client:
hydrate()
   ↓
cache restored
   ↓
useQuery()

Установка

npm install @tanstack/react-query

Для React:

import {
  QueryClient,
  QueryClientProvider,
  HydrationBoundary,
  dehydrate,
  useQuery,
} from "@tanstack/react-query";

QueryClient на сервере

Во время SSR нельзя использовать глобальный singleton QueryClient.

Неправильный вариант:

const queryClient = new QueryClient();

Причины:

  • утечки данных между пользователями;
  • shared cache;
  • race conditions;
  • нарушение изоляции запросов.

Правильный подход — новый QueryClient на каждый HTTP-request.

export async function renderPage() {
  const queryClient = new QueryClient();

  return queryClient;
}

PrefetchQuery

Основной метод серверной загрузки:

await queryClient.prefetchQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

После выполнения данные оказываются в кеше.


Разница между prefetchQuery и fetchQuery

prefetchQuery

await queryClient.prefetchQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

Особенности:

  • не выбрасывает ошибку наружу;
  • предназначен для предварительного наполнения кеша;
  • безопасен для SSR;
  • ошибки сохраняются в query state.

fetchQuery

const posts = await queryClient.fetchQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

Особенности:

  • возвращает данные;
  • выбрасывает исключение;
  • чаще используется в imperative-логике.

Полный SSR-пример

API

async function fetchPosts() {
  const response = await fetch(
    "https://jsonplaceholder.typicode.com/posts"
  );

  if (!response.ok) {
    throw new Error("Request failed");
  }

  return response.json();
}

Сервер

import {
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";

export async function serverRender() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

  return {
    dehydratedState: dehydrate(queryClient),
  };
}

Клиент

import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

function Posts() {
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

  return (
    <div>
      {data.map(post => (
        <div key={post.id}>
          {post.title}
        </div>
      ))}
    </div>
  );
}

export default function App({ dehydratedState }) {
  const [queryClient] = React.useState(
    () => new QueryClient()
  );

  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={dehydratedState}>
        <Posts />
      </HydrationBoundary>
    </QueryClientProvider>
  );
}

Dehydrate

dehydrate() сериализует Query Cache.

const dehydratedState = dehydrate(queryClient);

Результат:

{
  queries: [...],
  mutations: [...]
}

В dehydrated state входят:

  • query key;
  • данные;
  • timestamps;
  • статусы;
  • metadata;
  • ошибки;
  • stale state.

Hydration

Hydration восстанавливает кеш на клиенте.

<HydrationBoundary state={dehydratedState}>
  <App />
</HydrationBoundary>

После гидратации:

useQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

не выполняет немедленный запрос, потому что данные уже существуют в кеше.


Почему запросы всё равно могут повторяться

Даже после hydration запрос может автоматически перезапуститься.

Причина — stale state.

По умолчанию:

staleTime: 0

Это означает:

данные считаются устаревшими сразу после получения

Поэтому клиент выполняет background refetch.


Управление staleTime

Для SSR почти всегда нужен staleTime.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
    },
  },
});

Теперь:

  • hydration использует серверные данные;
  • клиент не делает немедленный refetch;
  • запросы сокращаются;
  • UI становится стабильнее.

Prefetch нескольких запросов

Server-side prefetching обычно используется сразу для нескольких query.

await Promise.all([
  queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  }),

  queryClient.prefetchQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
  }),

  queryClient.prefetchQuery({
    queryKey: ["comments"],
    queryFn: fetchComments,
  }),
]);

Параллельная загрузка существенно ускоряет SSR.


Последовательный prefetch

Иногда запросы зависят друг от друга.

const user = await queryClient.fetchQuery({
  queryKey: ["user", id],
  queryFn: () => fetchUser(id),
});

await queryClient.prefetchQuery({
  queryKey: ["projects", user.id],
  queryFn: () => fetchProjects(user.id),
});

Prefetch Infinite Query

Для бесконечных списков используется:

await queryClient.prefetchInfiniteQuery({
  queryKey: ["feed"],
  queryFn: fetchFeed,
  initialPageParam: 0,
});

SSR и useInfiniteQuery

const {
  data,
  fetchNextPage,
} = useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: fetchFeed,
  initialPageParam: 0,
  getNextPageParam: lastPage => {
    return lastPage.nextCursor;
  },
});

Hydration корректно восстанавливает:

  • pages;
  • pageParams;
  • pagination state.

Selective dehydration

Не все запросы необходимо сериализовывать.

dehydrate(queryClient, {
  shouldDehydrateQuery: query => {
    return query.queryKey[0] !== "admin";
  },
});

Это помогает:

  • уменьшить payload;
  • сократить размер HTML;
  • скрыть приватные данные;
  • снизить memory overhead.

Исключение ошибок из hydration

Иногда ошибки не должны попадать в клиентский state.

dehydrate(queryClient, {
  shouldDehydrateQuery: query => {
    return query.state.status === "success";
  },
});

Размер dehydrated state

Крупные SSR-приложения могут сериализовывать мегабайты данных.

Проблемы:

  • медленный TTFB;
  • рост HTML;
  • ухудшение hydration;
  • увеличение memory usage.

Частые причины:

  • слишком большие query;
  • сериализация списков;
  • дублирование данных;
  • prefetch ненужных страниц.

Стратегии уменьшения payload

Prefetch только critical data

await queryClient.prefetchQuery({
  queryKey: ["hero"],
  queryFn: fetchHero,
});

Не стоит prefetch:

  • скрытые табы;
  • модальные окна;
  • редко используемые панели;
  • offscreen-контент.

Использование select

useQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
  select: data => {
    return data.slice(0, 10);
  },
});

Разделение query

Плохо:

["dashboard"]

Лучше:

["dashboard", "stats"]
["dashboard", "feed"]
["dashboard", "notifications"]

Next.js Pages Router

Классический SSR:

export async function getServerSideProps() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

Next.js App Router

В App Router server components могут выполнять prefetch напрямую.

Server Component

import {
  QueryClient,
  dehydrate,
  HydrationBoundary,
} from "@tanstack/react-query";

export default async function Page() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

  return (
    <HydrationBoundary
      state={dehydrate(queryClient)}
    >
      <Posts />
    </HydrationBoundary>
  );
}

Client Component

"use client";

function Posts() {
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });

  return (
    <div>
      {data.map(post => (
        <div key={post.id}>
          {post.title}
        </div>
      ))}
    </div>
  );
}

Streaming SSR

React 18 поддерживает streaming SSR.

TanStack Query совместим со streaming, но необходимо учитывать:

  • hydration может происходить частями;
  • запросы могут завершаться в разное время;
  • Suspense влияет на timing.

Prefetch и Suspense

useSuspenseQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

При корректном server prefetch Suspense fallback не отображается, потому что данные уже существуют в кеше.


Ошибки во время SSR

Ошибка prefetch не должна разрушать весь SSR-рендер.

await queryClient.prefetchQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

Если запрос завершится ошибкой:

  • HTML всё равно может быть сгенерирован;
  • useQuery покажет error state;
  • hydration продолжится.

Жёсткая обработка ошибок

Иногда SSR необходимо остановить.

try {
  await queryClient.fetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  });
} catch (error) {
  return {
    notFound: true,
  };
}

Prefetch dependent queries

await queryClient.prefetchQuery({
  queryKey: ["session"],
  queryFn: fetchSession,
});

const session = queryClient.getQueryData([
  "session",
]);

if (session) {
  await queryClient.prefetchQuery({
    queryKey: ["profile", session.userId],
    queryFn: () => fetchProfile(session.userId),
  });
}

getQueryData после prefetch

После prefetch данные доступны синхронно.

await queryClient.prefetchQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

const posts = queryClient.getQueryData([
  "posts",
]);

PrefetchQuery и кеширование HTTP

Server-side prefetching хорошо сочетается с:

  • CDN;
  • edge caching;
  • HTTP cache;
  • ISR;
  • stale-while-revalidate.

Prefetch и авторизация

Во время SSR необходимо учитывать cookies и headers.

await queryClient.prefetchQuery({
  queryKey: ["me"],
  queryFn: () => fetchMe(req.headers.cookie),
});

Изоляция пользовательских данных

Категорически нельзя:

const globalClient = new QueryClient();

Иначе:

  • пользователь A может получить данные пользователя B;
  • кеш становится shared;
  • возможны security leaks.

Prefetch и React Server Components

В RSC сервер может загружать данные без useQuery.

Однако TanStack Query остаётся полезен для:

  • shared cache;
  • background refetch;
  • mutations;
  • optimistic updates;
  • клиентской синхронизации;
  • invalidation.

Hydration mismatch

Ошибка hydration mismatch возникает, когда:

  • сервер и клиент получают разные данные;
  • queryKey отличаются;
  • используется нестабильная сериализация;
  • queryFn возвращает разные структуры.

Нестабильные queryKey

Плохо:

queryKey: ["posts", new Date()]

Плохо:

queryKey: ["posts", Math.random()]

Правильно:

queryKey: ["posts", page]

Сериализация данных

Dehydration использует JSON serialization.

Проблемы возникают с:

  • Date;
  • Map;
  • Set;
  • BigInt;
  • class instances;
  • functions.

Проблема Date

{
  createdAt: new Date()
}

После hydration:

createdAt // string

Нормализация данных

Лучше сериализовать вручную.

return posts.map(post => ({
  ...post,
  createdAt: post.createdAt.toISOString(),
}));

Prefetch и stale-while-revalidate

Частая стратегия:

staleTime: 5 * 60 * 1000

Поведение:

  • пользователь получает SSR-данные мгновенно;
  • клиент не делает refetch 5 минут;
  • после устаревания начинается background update.

CacheTime на сервере

На сервере gcTime обычно менее важен, потому что QueryClient живёт недолго.

Но при long-running SSR environments настройки становятся актуальными:

new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60,
    },
  },
});

Предзагрузка роутов

Некоторые фреймворки позволяют prefetch route data заранее.

TanStack Query может подготавливать кеш до навигации:

queryClient.prefetchQuery({
  queryKey: ["product", id],
  queryFn: () => fetchProduct(id),
});

Prefetch в layout

В крупных приложениях часть запросов загружается глобально.

Пример:

  • session;
  • permissions;
  • navigation;
  • feature flags.
await Promise.all([
  queryClient.prefetchQuery({
    queryKey: ["session"],
    queryFn: fetchSession,
  }),

  queryClient.prefetchQuery({
    queryKey: ["menu"],
    queryFn: fetchMenu,
  }),
]);

Prefetch и SEO

SSR-prefetch особенно важен для:

  • поисковых систем;
  • Open Graph;
  • social previews;
  • индексации контента.

Без prefetch поисковый робот может увидеть только loading state.


Prefetch и TTFB

Слишком большой prefetch ухудшает TTFB.

Баланс между:

  • количеством SSR-данных;
  • скоростью ответа;
  • hydration cost;
  • network payload.

Prefetch waterfall

Плохой SSR:

await queryClient.prefetchQuery(...);
await queryClient.prefetchQuery(...);
await queryClient.prefetchQuery(...);

Возникает waterfall.

Лучше:

await Promise.all([
  queryClient.prefetchQuery(...),
  queryClient.prefetchQuery(...),
  queryClient.prefetchQuery(...),
]);

Дедупликация запросов

TanStack Query автоматически дедуплицирует одинаковые запросы.

await Promise.all([
  queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  }),

  queryClient.prefetchQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
  }),
]);

Реальный network request будет один.


Использование ensureQueryData

Современная альтернатива:

await queryClient.ensureQueryData({
  queryKey: ["posts"],
  queryFn: fetchPosts,
});

Поведение:

  • возвращает кешированные данные;
  • делает fetch только при необходимости;
  • удобно для SSR и loaders.

Prefetch и memory leaks

Опасность возникает при:

  • singleton QueryClient;
  • бесконечном кеше;
  • огромных dehydrated state;
  • long-lived Node.js process.

Server prefetching в production

На production SSR-системы обычно используют:

  • selective hydration;
  • route-level prefetch;
  • CDN caching;
  • partial rendering;
  • edge rendering;
  • staleTime tuning;
  • streaming SSR;
  • background refetch;
  • Suspense boundaries.

Архитектурная схема production SSR

Request
   ↓
Create QueryClient
   ↓
Parallel prefetch
   ↓
dehydrate()
   ↓
HTML + state
   ↓
Browser
   ↓
hydrate()
   ↓
Instant cache access
   ↓
Background refetch