ITSkillsCenter
Blog

TanStack Query SSR avec TanStack Start : prefetch, dehydrate et HydrationBoundary en 2026

13 min de lecture

Le rendu serveur avec TanStack Start fait gagner un round-trip réseau, mais il oblige à transporter l’état de cache du serveur vers le client sans rejouer les requêtes au montage. C’est exactement le rôle du couple dehydrate/HydrationBoundary de TanStack Query. Ce tutoriel montre comment câbler proprement TanStack Query v5 dans une application TanStack Start en 2026, depuis l’installation jusqu’à l’invalidation après mutation, en passant par le prefetch dans un loader de route et l’usage de useSuspenseQuery côté composant.

Article de la série autour de TanStack Start en production 2026.

L’enjeu n’est pas seulement de faire tourner du SSR : il s’agit d’éviter les deux pièges classiques. Premier piège, le waterfall : le composant monte côté client, lance la requête, et l’utilisateur voit un spinner alors que le HTML est déjà arrivé. Deuxième piège, l’hydratation cassée : le serveur a fetché la donnée mais le client la refetch quand même parce que le cache n’a pas été transporté correctement. La combinaison prefetchQuery dans le loader, dehydrate au moment de la sérialisation du router et HydrationBoundary au moment du rendu règle ces deux problèmes en une seule passe.

Prérequis

Avant de commencer, on suppose que TanStack Start est déjà installé et fonctionne en mode SSR. Si ce n’est pas le cas, le tutoriel Setup TanStack Start v1 couvre la mise en route. On suppose aussi un niveau React intermédiaire : composants fonctionnels, hooks, notion de Suspense et compréhension de la différence entre rendu serveur et rendu client.

  • Node.js 22 LTS minimum, parce que TanStack Start exige les API Web fetch natives modernes.
  • Un projet TanStack Start initialisé avec un fichier src/router.tsx qui exporte une fonction createRouter.
  • TypeScript activé, ce qui n’est pas obligatoire mais rend la propagation du context du router beaucoup plus lisible.
  • Connaissance des routes basées fichiers décrite dans le tutoriel sur le file-based routing TanStack Router.

Étape 1 — Installer @tanstack/react-query et les devtools

La première opération consiste à ajouter le paquet TanStack Query et ses devtools au projet. Les devtools ne sont pas optionnelles en pratique : pendant le développement, elles permettent de visualiser quelles requêtes ont été hydratées depuis le serveur, lesquelles sont stale et lesquelles déclenchent un refetch.

npm install @tanstack/react-query @tanstack/react-query-devtools

Une fois la commande exécutée, on doit retrouver les deux dépendances dans package.json avec une version ^5.x. La version 5 est celle qui introduit HydrationBoundary à la place de l’ancien Hydrate et qui sépare proprement useQuery et useSuspenseQuery. Si vous voyez une version 4, mettez à jour avant d’aller plus loin sinon les exemples ne compileront pas.

Le contrôle simple : npm ls @tanstack/react-query affiche une seule ligne, sans warning de duplicat. Si deux versions cohabitent à cause d’un autre paquet, l’hydratation va échouer silencieusement parce que le contexte React Query du serveur ne correspondra pas à celui du client.

Étape 2 — Configurer le QueryClient avec staleTime et gcTime

Le QueryClient est l’objet central qui contient le cache. En SSR, il faut absolument une instance par requête HTTP côté serveur, jamais une instance globale partagée, sinon les utilisateurs vont voir les données les uns des autres. C’est précisément la raison pour laquelle on l’instancie à l’intérieur de createRouter plus loin. Avant ça, on isole la fabrique du QueryClient dans un fichier dédié pour pouvoir la réutiliser sans dupliquer la configuration.

// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        gcTime: 5 * 60_000,
        retry: 1,
        refetchOnWindowFocus: false,
      },
    },
  })
}

Deux options méritent une explication. staleTime: 60_000 dit à React Query qu’une donnée fraîchement fetchée reste considérée fraîche pendant 60 secondes : pendant ce délai, aucun refetch automatique ne se déclenchera. C’est crucial en SSR parce que sans staleTime non nul, la donnée hydratée serait immédiatement marquée stale au montage côté client et React Query relancerait la requête. gcTime: 5 * 60_000 contrôle combien de temps une requête inutilisée reste en cache avant garbage collection : cinq minutes est une valeur saine pour la plupart des écrans.

Étape 3 — Brancher le QueryClient dans le context du router

TanStack Router accepte un context qu’on peut utiliser pour injecter des dépendances accessibles depuis n’importe quelle route, y compris dans les loader. C’est ce mécanisme qu’on exploite pour rendre le QueryClient disponible côté serveur au moment du prefetch et côté client au moment du montage.

// src/router.tsx
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { makeQueryClient } from './lib/query-client'

export function createRouter() {
  const queryClient = makeQueryClient()

  return createTanStackRouter({
    routeTree,
    context: { queryClient },
    defaultPreload: 'intent',
  })
}

declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof createRouter>
  }
}

La fonction createRouter est appelée par TanStack Start une fois par requête côté serveur et une seule fois côté client : on a donc bien une isolation des caches entre utilisateurs. Le context est typé via la déclaration de module Register, ce qui rend context.queryClient autocompletable dans tous les loaders.

Étape 4 — Mettre en place le Provider et la HydrationBoundary dans __root.tsx

Maintenant qu’on a un QueryClient par requête transporté via le router, il faut l’exposer à l’arbre React via QueryClientProvider, et envelopper l’application dans HydrationBoundary pour que les données dehydratées soient effectivement injectées dans le cache au montage.

// src/routes/__root.tsx
import {
  createRootRouteWithContext,
  Outlet,
  HeadContent,
  Scripts,
} from '@tanstack/react-router'
import {
  QueryClient,
  QueryClientProvider,
  HydrationBoundary,
  dehydrate,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient
}>()({
  component: RootComponent,
  loader: ({ context }) => ({ dehydratedState: dehydrate(context.queryClient) }),
})

function RootComponent() {
  const { queryClient } = Route.useRouteContext()
  const { dehydratedState } = Route.useLoaderData()

  return (
    <html>
      <head><HeadContent /></head>
      <body>
        <QueryClientProvider client={queryClient}>
          <HydrationBoundary state={dehydratedState}>
            <Outlet />
          </HydrationBoundary>
          <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
        <Scripts />
      </body>
    </html>
  )
}

L’élément à comprendre, c’est que HydrationBoundary reçoit l’état dehydraté et le pousse dans le QueryClient au premier rendu. Sur les rendus suivants, elle ne fait plus rien : c’est uniquement un mécanisme de bootstrap. La donnée transite ensuite par le cache normal de React Query, donc les hooks useQuery et useSuspenseQuery appelés plus bas dans l’arbre voient les données comme si elles avaient été fetchées localement.

Étape 5 — Prefetch dans le loader d’une route

On arrive au cœur du sujet : déclencher la requête côté serveur pendant que le router résout la route, pour que la donnée soit déjà en cache au moment du rendu. TanStack Router donne accès au context du router dans chaque loader, donc au queryClient.

// src/routes/articles/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions } from '@tanstack/react-query'

export const articlesQueryOptions = queryOptions({
  queryKey: ['articles', 'list'],
  queryFn: async () => {
    const res = await fetch('https://api.example.com/articles')
    if (!res.ok) throw new Error('Articles fetch failed')
    return res.json() as Promise<Array<{ id: string; title: string }>>
  },
  staleTime: 60_000,
})

export const Route = createFileRoute('/articles/')({
  loader: async ({ context }) => {
    await context.queryClient.prefetchQuery(articlesQueryOptions)
  },
  component: ArticlesPage,
})

function ArticlesPage() {
  return null
}

Trois éléments structurants. D’abord, on extrait les options de la requête dans une constante articlesQueryOptions via le helper queryOptions : c’est ce qui permet de partager la même queryKey et la même queryFn entre le loader et le composant sans risquer une coquille. Ensuite, queryClient.prefetchQuery ne renvoie pas les données mais une promesse qui résout quand le cache est rempli ; c’est exactement ce qu’on attend dans un loader. À ce stade, en chargeant /articles, le serveur doit faire un appel HTTP vers l’API, et le HTML retourné doit déjà contenir les titres des articles.

Étape 6 — Lire la donnée avec useSuspenseQuery côté composant

La donnée est en cache, il reste à la consommer. Il y a deux choix : useQuery, qui renvoie un objet avec data, isLoading, error, ou useSuspenseQuery, qui suspend le rendu tant que la donnée n’est pas là et lève une erreur captée par un ErrorBoundary. En SSR avec TanStack Start, useSuspenseQuery est nettement préférable parce qu’il garantit qu’aucun rendu intermédiaire avec data === undefined n’a lieu.

// src/routes/articles/index.tsx (suite)
import { useSuspenseQuery } from '@tanstack/react-query'

function ArticlesPage() {
  const { data } = useSuspenseQuery(articlesQueryOptions)

  return (
    <ul>
      {data.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  )
}

Comme la requête a été prefetchée dans le loader, useSuspenseQuery trouve la donnée en cache au premier rendu serveur et ne suspend pas. Côté client, après hydratation, la donnée est aussi déjà là grâce à HydrationBoundary. Si le staleTime n’a pas expiré, aucun refetch n’a lieu : le résultat est un montage instantané sans spinner. Le test pratique consiste à désactiver JavaScript dans le navigateur et à recharger la page : la liste des articles doit toujours s’afficher, parce qu’elle vient du HTML serveur.

Étape 7 — Invalider le cache après une mutation

Une fois que les requêtes en lecture sont câblées, on a besoin que le cache se mette à jour quand l’utilisateur modifie une donnée. C’est le rôle de useMutation couplé à queryClient.invalidateQueries. La logique : la mutation s’exécute, en cas de succès on invalide la clé concernée, ce qui marque les requêtes comme stale et déclenche un refetch des composants montés qui les consomment.

// src/routes/articles/new.tsx
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'

export const Route = createFileRoute('/articles/new')({
  component: NewArticlePage,
})

function NewArticlePage() {
  const router = useRouter()
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: async (payload: { title: string }) => {
      const res = await fetch('https://api.example.com/articles', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(payload),
      })
      if (!res.ok) throw new Error('Create failed')
      return res.json()
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['articles'] })
      router.navigate({ to: '/articles' })
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const fd = new FormData(e.currentTarget)
        mutation.mutate({ title: String(fd.get('title') ?? '') })
      }}
    >
      <input name="title" />
      <button disabled={mutation.isPending}>Créer</button>
    </form>
  )
}

La queryKey passée à invalidateQueries est un préfixe : ['articles'] invalide aussi ['articles', 'list'] et ['articles', 'detail', id], ce qui est généralement le comportement souhaité. L’await sur l’invalidation avant la navigation garantit que la liste est rafraîchie avant que l’utilisateur la voie ; sans await, on a une fenêtre où l’ancienne liste reste affichée pendant que le refetch est en cours. Pour aller plus loin, le tutoriel sur les server functions TanStack Start explique comment remplacer ces appels fetch bruts par des RPC typés.

Étape 8 — Gestion d’erreur avec ErrorBoundary

Avec useSuspenseQuery, les erreurs ne reviennent pas dans error — elles sont relancées au rendu et doivent être attrapées par un ErrorBoundary. TanStack Router fournit le sien au niveau de chaque route via errorComponent.

// src/routes/articles/index.tsx (extension)
export const Route = createFileRoute('/articles/')({
  loader: async ({ context }) => {
    await context.queryClient.prefetchQuery(articlesQueryOptions)
  },
  component: ArticlesPage,
  errorComponent: ({ error, reset }) => (
    <div role="alert">
      <p>Erreur de chargement : {error.message}</p>
      <button onClick={reset}>Réessayer</button>
    </div>
  ),
})

Cette configuration capture aussi bien les erreurs levées par queryFn côté serveur que celles levées côté client après hydratation. Le bouton reset rejoue le loader, ce qui re-déclenche le prefetch.

Patterns de cache : staleTime court vs long

Le choix du staleTime dépend de la nature de la donnée. Le tableau suivant résume les cas typiques rencontrés en production.

Type de donnée staleTime gcTime Justification
Liste d’articles publics 60 000 ms (1 min) 5 min Rare changement, hydratation fluide
Profil utilisateur connecté 30 000 ms 5 min Peut changer côté serveur
Notifications temps réel 0 1 min Toujours rafraîchir au focus
Catalogue produit 5 min 15 min Stable, on évite la charge API
Tableau de bord financier 10 000 ms 2 min Précision plus importante que perf
Configuration applicative Infinity 30 min Ne change qu’au déploiement

Une règle pratique : si la donnée est déjà transportée par SSR, staleTime doit être strictement supérieur à zéro, sinon le client refetch immédiatement et le bénéfice du prefetch est perdu. Sur les écrans haute fréquence, on règle plutôt refetchInterval que staleTime: 0.

Erreurs fréquentes

Les erreurs ci-dessous reviennent dans la majorité des intégrations qui débutent avec TanStack Query SSR. Les reconnaître évite des heures de debug.

Erreur Cause Solution
QueryClient global Instance partagée au niveau module Toujours instancier dans la fabrique du router
queryKey divergente loader/composant Constante non partagée Centraliser via queryOptions
staleTime à zéro avec SSR Refetch immédiat à l’hydratation Régler à au moins 30-60 secondes
Oubli du await sur prefetch Loader rend la main avant cache rempli Toujours await dans le loader
useQuery au lieu de useSuspenseQuery État isLoading qui clignote Préférer useSuspenseQuery en SSR
HydrationBoundary trop bas Hydratation rejouée à chaque montage Placer juste sous QueryClientProvider
Mutation sans invalidation UI montre l’ancienne donnée Brancher invalidateQueries dans onSuccess

FAQ

Faut-il utiliser ensureQueryData ou prefetchQuery dans le loader ? prefetchQuery ne lève pas d’erreur si la requête échoue, ce qui laisse le composant la traiter via son ErrorBoundary. ensureQueryData renvoie la donnée et lève en cas d’échec, ce qui fait remonter l’erreur dans le loader et déclenche errorComponent de la route. Pour la plupart des écrans, prefetchQuery donne un meilleur découpage.

Peut-on utiliser TanStack Query sans le loader, en s’appuyant uniquement sur useSuspenseQuery ? Techniquement oui, mais on perd le SSR : le serveur rend un fallback, le client monte, suspend, fetch, puis affiche. Le loader est ce qui transforme le pattern en vrai SSR avec prefetch.

Comment partager le QueryClient entre loader et composant qui n’est pas dans la même route ? Le queryClient est dans le contexte du router, accessible via Route.useRouteContext() dans n’importe quel composant de route, et via useQueryClient() à l’intérieur de l’arbre QueryClientProvider. Les deux pointent vers la même instance.

Que se passe-t-il si l’API renvoie une erreur côté serveur ? prefetchQuery capte l’erreur silencieusement et la met en cache comme état d’erreur. Au moment du rendu, useSuspenseQuery la relance, et l’errorComponent de la route l’affiche. Le HTML rendu contient déjà le composant d’erreur, sans round-trip supplémentaire.

TanStack Query remplace-t-il un store global comme Zustand ou Redux ? Non : Query gère l’état serveur (données distantes, cache, invalidation), pas l’état UI local (modale ouverte, formulaire en cours, thème). Le découpage entre les deux est détaillé dans le panorama du state management React en 2026.

Ressources

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité