ITSkillsCenter
Blog

Server functions TanStack Start : RPC type-safe full-stack en 2026

13 min de lecture

Les server functions de TanStack Start changent la façon d’écrire du code full-stack en React en 2026. Au lieu de créer une route API, sérialiser manuellement la requête, retyper la réponse côté client, vous déclarez une fonction serveur avec createServerFn, vous l’exposez à React et vous obtenez un appel RPC type-safe de bout en bout. Les types TypeScript se propagent du handler jusqu’au composant, sans génération de code, sans schéma OpenAPI, sans client généré. Ce tutoriel montre comment construire une chaîne complète : lecture en base avec Drizzle, validation par Zod, mutation, gestion d’erreurs, middleware d’authentification, upload de fichier, et orchestration avec TanStack Query.

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

Prérequis

Avant d’écrire la première server function, votre projet doit déjà être bootstrappé sur TanStack Start v1, en mode SSR par défaut. Si l’un de ces points vous manque, revenez à l’article principal pour le combler avant de continuer.

  • Node.js 22 LTS installé localement (node -v doit renvoyer une version 22.x).
  • Un projet TanStack Start généré via npm create tanstack@latest avec TypeScript activé.
  • TanStack Router et TanStack Query déjà branchés (le starter officiel le fait par défaut).
  • Une base PostgreSQL accessible et Drizzle ORM configuré avec drizzle-kit récent (0.45+ ou v1 beta).
  • Zod installé (npm install zod) pour les validateurs.
  • Un éditeur avec serveur TypeScript actif : sans cela vous perdez tout l’intérêt RPC du dispositif.

Une fois ces briques en place, créez le dossier app/server/ dans lequel nous regrouperons les server functions. La convention n’est pas obligatoire, mais elle aide énormément à séparer mentalement le code qui s’exécute uniquement côté serveur.

Étape 1 — Créer une server function basique

Commençons par une lecture simple : récupérer une liste de posts. Le but est de comprendre la mécanique de createServerFn avant d’ajouter les couches plus complexes. Une server function se construit comme un builder : on déclare la méthode HTTP, on attache éventuellement un validateur, puis on termine par .handler(). Le retour est une fonction asynchrone qui peut être appelée directement depuis n’importe quel composant React.

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'

export const getPosts = createServerFn({ method: 'GET' })
  .handler(async () => {
    return [
      { id: 1, title: 'Premier post', body: 'Hello server function' },
      { id: 2, title: 'Deuxième post', body: 'TanStack Start v1' },
    ]
  })

À l’exécution, TanStack Start transforme cet appel en endpoint HTTP interne. Quand vous l’invoquez côté client, le bundler retire le corps du handler du bundle navigateur et le remplace par un fetch typé. Le signal de réussite est simple : depuis un composant, l’auto-complétion sur getPosts() doit vous montrer le type Promise<{ id: number; title: string; body: string }[]> sans aucune annotation manuelle.

Étape 2 — Ajouter un validateur Zod

Une server function sans validateur est une porte ouverte. N’importe quel client peut appeler l’endpoint avec un payload arbitraire. Le builder expose une méthode .inputValidator() qui accepte une fonction de validation ou un schéma compatible (Zod, Valibot, ArkType). Le validateur s’exécute côté serveur avant le handler ; si la validation échoue, le client reçoit une erreur sérialisée et le handler n’est jamais appelé.

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'

const getPostByIdSchema = z.object({
  id: z.number().int().positive(),
})

export const getPostById = createServerFn({ method: 'GET' })
  .inputValidator(getPostByIdSchema)
  .handler(async ({ data }) => {
    return { id: data.id, title: `Post ${data.id}`, body: 'lorem' }
  })

Notez que le validateur reçoit toujours unknown : c’est volontaire pour forcer le parsing strict. En production, si quelqu’un appelle getPostById({ data: { id: 'abc' } }), Zod lève ZodError, TanStack Start la sérialise et le composant reçoit une erreur propre. Le retour de .parse() devient automatiquement le type de data.

Étape 3 — Lire en base avec Drizzle

Une fois la validation en place, il est temps de remplacer la donnée mockée par un vrai accès base. Drizzle ORM fonctionne très bien dans une server function parce que le code n’est jamais bundlé côté client : aucun risque d’embarquer un client PostgreSQL dans le navigateur. On importe le client Drizzle, on écrit la requête comme dans n’importe quel script Node, et on retourne le résultat.

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { db } from '~/db/client'
import { posts } from '~/db/schema'

export const getPostById = createServerFn({ method: 'GET' })
  .inputValidator(z.object({ id: z.number().int().positive() }))
  .handler(async ({ data }) => {
    const rows = await db.select().from(posts).where(eq(posts.id, data.id))
    if (rows.length === 0) {
      throw new Error(`Post ${data.id} introuvable`)
    }
    return rows[0]
  })

Le type de retour est inféré directement depuis Drizzle, donc côté composant vous obtenez les colonnes exactes de la table posts sans dupliquer la définition. Si vous renommez une colonne dans le schéma Drizzle, TypeScript signale l’incohérence dans tous les composants qui consomment cette server function.

Étape 4 — Écrire une mutation et invalider TanStack Query

Une mutation suit le même schéma, à ceci près qu’on déclare method: 'POST'. La vraie subtilité est ailleurs : après une mutation, le cache TanStack Query côté client doit être invalidé pour que la liste se rafraîchisse. La server function ignore tout du cache ; elle se contente de muter la base. C’est le composant React qui orchestre l’invalidation via queryClient.invalidateQueries.

// app/server/posts.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '~/db/client'
import { posts } from '~/db/schema'

export const createPost = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
      title: z.string().min(3).max(120),
      body: z.string().min(1),
    }))
  .handler(async ({ data }) => {
    const [row] = await db.insert(posts).values(data).returning()
    return row
  })

Le retour .returning() de Drizzle nous donne directement le post créé avec son id. Côté composant, on combine cette server function avec useMutation de TanStack Query, et dans onSuccess on invalide la clé qui sert la liste. Cette séparation est saine : le serveur se concentre sur la persistance, le client gère l’état UI et le cache.

Étape 5 — Consommer la server function dans un composant React

Voyons maintenant comment brancher ces fonctions dans une vraie page. TanStack Query reste la couche idiomatique pour gérer chargement, erreurs et cache. useQuery appelle la server function comme une simple fonction queryFn, et useMutation orchestre l’écriture suivie d’une invalidation. C’est ici qu’on voit l’effet RPC : aucune URL, aucun verbe HTTP, pas de fetch manuel.

// app/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { createPost, getPosts } from '~/server/posts'

export const Route = createFileRoute('/posts')({ component: PostsPage })

function PostsPage() {
  const qc = useQueryClient()
  const [title, setTitle] = useState('')

  const { data, isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: () => getPosts(),
  })

  const mutation = useMutation({
    mutationFn: (vars: { title: string; body: string }) =>
      createPost({ data: vars }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['posts'] }),
  })

  if (isLoading) return <p>Chargement…</p>

  return (
    <div>
      <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button onClick={() => mutation.mutate({ title, body: 'corps' })}>
        Créer
      </button>
    </div>
  )
}

Au clic sur le bouton, useMutation envoie la requête, attend la réponse typée, puis invalide la query ['posts']. TanStack Query relance alors getPosts() et l’UI se met à jour. Aucun fetch, aucun JSON.parse, aucun any : tout est inféré depuis le serveur.

Étape 6 — Gérer les erreurs proprement

Quand une server function lève une erreur, TanStack Start la sérialise et la rejoue côté client. Le réflexe naïf est de catcher dans le composant, mais TanStack Router fournit un mécanisme plus élégant : la propriété errorComponent de la route capture toute erreur non gérée et affiche un fallback. Combiné à useSuspenseQuery, cela donne une chaîne d’erreurs cohérente du serveur jusqu’à l’UI.

// app/routes/posts.$id.tsx
import { createFileRoute, ErrorComponent } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
import { getPostById } from '~/server/posts'

export const Route = createFileRoute('/posts/$id')({
  component: PostPage,
  errorComponent: ({ error }) => <ErrorComponent error={error} />,
  loader: ({ params }) => getPostById({ data: { id: Number(params.id) } }),
})

function PostPage() {
  const { id } = Route.useParams()
  const { data } = useSuspenseQuery({
    queryKey: ['post', id],
    queryFn: () => getPostById({ data: { id: Number(id) } }),
  })
  return <article><h1>{data.title}</h1><p>{data.body}</p></article>
}

Si getPostById lève (post introuvable, panne base, validateur Zod en échec), errorComponent reçoit l’objet sérialisé et l’utilisateur voit un message clair au lieu d’un écran blanc. Pour un comportement encore plus fin, vous pouvez sous-classer Error côté serveur et tester error instanceof MyError côté client.

Étape 7 — Brancher un middleware d’authentification

Toutes les server functions n’ont pas vocation à être publiques. createMiddleware permet d’écrire une fonction qui s’exécute avant le handler, lit les cookies via getWebRequest(), vérifie la session, et passe le résultat au handler via context. Le middleware s’attache ensuite avec .middleware([authMiddleware]) sur toute server function qui doit être protégée.

// app/server/middleware/auth.ts
import { createMiddleware } from '@tanstack/react-start'
import { verifySession } from '~/auth/session'

export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
  const cookie = request.headers.get('cookie') ?? ''
  const user = await verifySession(cookie)
  if (!user) throw new Error('Non authentifié')
  return next({ context: { user } })
})

Le context renvoyé par next() est typé et accessible dans le handler de la server function. C’est ainsi qu’on évite de relire la session dans chaque fonction. On l’attache à une server function de suppression :

// app/server/posts.ts (extrait protégé)
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from './middleware/auth'

export const deletePost = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .inputValidator(z.object({ id: z.number().int().positive() }))
  .handler(async ({ data, context }) => {
    await db.delete(posts).where(eq(posts.id, data.id))
    return { ok: true, by: context.user.email }
  })

Toute requête sans cookie valide se solde par une erreur Non authentifié avant même que le handler ne soit appelé. La logique métier reste lisible : pas de if (!user) return 401 répété partout. Pour aller un cran plus loin, vous pouvez chaîner plusieurs middlewares (rate limiting, audit log, vérification d’organisation).

Étape 8 — Uploader un fichier en multipart

Les server functions acceptent FormData en entrée, ce qui permet d’uploader des fichiers sans passer par une route API séparée. Le validateur reçoit alors un FormData brut, et c’est à vous d’en extraire les champs. Voici un exemple qui accepte une image, vérifie sa taille, puis la stocke sur disque ou via un client S3.

// app/server/upload.ts
import { createServerFn } from '@tanstack/react-start'
import { writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import path from 'node:path'

export const uploadAvatar = createServerFn({ method: 'POST' })
  .inputValidator((input: unknown) => {
    if (!(input instanceof FormData)) throw new Error('FormData attendu')
    const file = input.get('file')
    if (!(file instanceof File)) throw new Error('Champ file manquant')
    if (file.size > 5 * 1024 * 1024) throw new Error('Fichier trop gros')
    return { file }
  })
  .handler(async ({ data }) => {
    const buffer = Buffer.from(await data.file.arrayBuffer())
    const filename = `${randomUUID()}-${data.file.name}`
    await writeFile(path.join(process.cwd(), 'uploads', filename), buffer)
    return { url: `/uploads/${filename}` }
  })

Côté client, on appelle uploadAvatar({ data: formData }) avec un FormData construit depuis un <input type="file">. TanStack Start détecte automatiquement que le payload est un FormData et l’envoie en multipart/form-data sans configuration. Le retour { url } est typé et peut être stocké en base via une autre server function.

Server functions vs Server Actions Next.js

Les deux dispositifs visent le même objectif : effacer la frontière client/serveur. Le tableau suivant compare les choix techniques en 2026.

Dimension TanStack Start Next.js Server Actions
Déclaration createServerFn({ method }) explicite Directive 'use server' en tête de fichier
Validation .inputValidator() obligatoire conseillée, Zod natif À la charge du développeur dans le handler
Type-safety Inférence directe via le builder Inférence via paramètre, mais plus fragile sur FormData
Méthodes HTTP GET ou POST au choix POST uniquement
Middleware createMiddleware chaînable et typé Pas de middleware natif
Cache Délégué à TanStack Query côté client revalidatePath / revalidateTag intégrés
Sérialisation JSON, FormData, ReadableStream Superset JSON (Map, Set, Date) via React
Couplage framework Indépendant du routeur, agnostique sur le rendu Lié au routeur App Router

Il n’y a pas de gagnant absolu : Next.js mise sur la magie de la directive et l’intégration cache/route, TanStack Start mise sur l’explicite et la composition. Pour un projet où la lisibilité du data flow compte plus que le boilerplate, l’approche TanStack est souvent préférée.

Erreurs fréquentes

Erreur Cause Solution
Oublier le wrapper data Appel myFn({ id: 1 }) au lieu de myFn({ data: { id: 1 } }) Toujours envelopper le payload dans { data: ... }
Code serveur dans bundle client Import depuis un fichier hors route Placer la server function dans app/server/ et l’importer depuis une route
Validateur typé any (input: any) au lieu de (input: unknown) Utiliser unknown et laisser Zod inférer
Erreurs non sérialisables Objet d’erreur avec fonctions ou cycles Sous-classer Error simplement
UI obsolète après mutation Pas d’invalidateQueries Brancher dans onSuccess de useMutation
Fuite de variables d’environnement process.env lu dans un fichier partagé Isoler la config dans app/server/env.ts

FAQ

Faut-il une server function pour chaque appel API ? Non. Pour les API publiques tierces (météo, OpenAI, Stripe webhooks entrants), une route dédiée reste plus appropriée. Les server functions brillent quand le client React est aussi le client de l’API.

Peut-on appeler une server function depuis un autre serveur ? Techniquement oui, l’endpoint généré est un POST JSON, mais ce n’est pas l’usage prévu. Pour l’inter-service, exposez une route app/api/ classique avec authentification machine-to-machine.

Comment tester unitairement une server function ? Le handler reste une fonction asynchrone exportable. Importez-la dans un test Vitest et appelez-la avec un faux data. Pour tester le middleware, mockez getWebRequest via vi.mock.

Les server functions supportent-elles le streaming ? Oui. Retournez un ReadableStream depuis le handler, et côté client, lisez-le comme un stream fetch. C’est utile pour exposer des réponses LLM token par token.

Quelle différence entre server function et loader de route ? Le loader est appelé automatiquement par TanStack Router lors de la navigation et son résultat est mis en cache par route. Une server function est invoquée explicitement, à n’importe quel moment, et n’a pas de cycle de vie attaché à la route.

Pour creuser plus loin

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é