← Retour au blog

React Server Components : guide complet 2024

Ce que les Server Components changent vraiment. Patterns, pièges et migration depuis les Client Components.

Pourquoi les Server Components (et ce qu’ils ne sont PAS)

Ce que les Server Components ne sont PAS :

Ce qu’ils SONT :

Résultat : bundles JS -40%, performance +30%, meilleur SEO.

Différence entre SSR et Server Components

SSR (Next.js Pages Router)

// pages/users.tsx
export async function getServerSideProps() {"{"}
  const users = await db.users.findMany();
  return {"{"} props: {"{"} users {"}"} {"}"};
{"}"}

export default function Users({"{"} users {"}"}) {"{"}
  return (
    <div>
      {"{"}users.map(u => <UserCard key={"{"}u.id{"}"} user={"{"}u{"}"} />){"}"}
    </div>
  );
{"}"}

Problème : <UserCard> est hydraté côté client même s’il est statique → JavaScript inutile

Server Components (App Router)

// app/users/page.tsx
async function Users() {"{"}
  const users = await db.users.findMany();  // Direct !
  
  return (
    <div>
      {"{"}users.map(u => <UserCard key={"{"}u.id{"}"} user={"{"}u{"}"} />){"}"}
    </div>
  );
{"}"}

Avantage : <UserCard> reste côté serveur → 0 JavaScript envoyé au client

Pattern #1 : Data fetching côté serveur

Avant (Client Component)

'use client';

import {"{"} useEffect, useState {"}"} from 'react';

export default function Dashboard() {"{"}
  const [stats, setStats] = useState(null);
  
  useEffect(() => {"{"}
    fetch('/api/stats')
      .then(r => r.json())
      .then(setStats);
  {"}"}, []);
  
  if (!stats) return <Loading />;
  
  return <div>{"{"}stats.revenue{"}"}</div>;
{"}"}

Problèmes :

Après (Server Component)

// Pas besoin de 'use client'

import {"{"} db {"}"} from '@/lib/db';

async function Dashboard() {"{"}
  const stats = await db.stats.findFirst();
  
  return <div>{"{"}stats.revenue{"}"}</div>;
{"}"}

Avantages :

Pattern #2 : Composition Server + Client

Règle : Client Component = dès que vous utilisez hooks ou events

// app/dashboard/page.tsx (Server Component)
import {"{"} db {"}"} from '@/lib/db';
import {"{"} InteractiveChart {"}"} from './InteractiveChart';

async function Dashboard() {"{"}
  const data = await db.analytics.findMany();
  
  return (
    <div>
      <h1>Dashboard</h1>
      {"{"}/* Server Component */{"}"}
      <StaticStats data={"{"}data{"}"} />
      
      {"{"}/* Client Component (interactif) */{"}"}
      <InteractiveChart data={"{"}data{"}"} />
    </div>
  );
{"}"}
// app/dashboard/InteractiveChart.tsx
'use client';  // Client Component

import {"{"} useState {"}"} from 'react';
import {"{"} Chart {"}"} from 'recharts';

export function InteractiveChart({"{"} data {"}"}) {"{"}
  const [timeRange, setTimeRange] = useState('7d');
  
  return (
    <div>
      <select onChange={"{"}e => setTimeRange(e.target.value){"}"}>
        <option value="7d">7 jours</option>
        <option value="30d">30 jours</option>
      </select>
      <Chart data={"{"}data{"}"} range={"{"}timeRange{"}"} />
    </div>
  );
{"}"}

Astuce : passer data de Server → Client Component est OK (sérialisé automatiquement)

Pattern #3 : Streaming avec Suspense

Problème : page lente si une requête DB est longue

Solution : streamer les parties lentes

// app/dashboard/page.tsx
import {"{"} Suspense {"}"} from 'react';

export default function Dashboard() {"{"}
  return (
    <div>
      <h1>Dashboard</h1>
      
      {"{"}/* Charge immédiatement */{"}"}
      <FastStats />
      
      {"{"}/* S'affiche dès que prêt */{"}"}
      <Suspense fallback={"{"}< Loading />{"}"}>
        <SlowAnalytics />
      </Suspense>
      
      <Suspense fallback={"{"}< Loading />{"}"}>
        <VerySlowReports />
      </Suspense>
    </div>
  );
{"}"}

async function SlowAnalytics() {"{"}
  const analytics = await db.analytics.complex Query();  // 2s
  return <AnalyticsChart data={"{"}analytics{"}"} />;
{"}"}

Résultat :

Pattern #4 : Cache et revalidation

Cache automatique

// Par défaut, fetch() est cacché
async function Posts() {"{"}
  const posts = await fetch('https://api.blog.com/posts'); // Cacché ∞
  return <PostList posts={"{"}posts{"}"} />;
{"}"}

Revalidation périodique

async function Posts() {"{"}
  const posts = await fetch('https://api.blog.com/posts', {"{"}
    next: {"{"} revalidate: 60 {"}"} // Re-fetch toutes les 60s
  {"}"});
  return <PostList posts={"{"}posts{"}"} />;
{"}"}

Revalidation on-demand

// app/api/revalidate/route.ts
import {"{"} revalidatePath {"}"} from 'next/cache';

export async function POST() {"{"}
  revalidatePath('/posts');  // Invalide le cache de /posts
  return Response.json({"{"} revalidated: true {"}"});
{"}"}

Trigger : webhook depuis CMS, cron job, etc.

Pattern #5 : Parallel data fetching

❌ Waterfall (lent)

async function Dashboard() {"{"}
  const user = await db.user.findFirst();
  const posts = await db.posts.findMany({"{"} where: {"{"} userId: user.id {"}"} {"}"});
  const comments = await db.comments.findMany({"{"} where: {"{"} userId: user.id {"}"} {"}"});
  
  return ...
{"}"}
// user (200ms) → posts (150ms) → comments (100ms) = 450ms total

✅ Parallèle (rapide)

async function Dashboard() {"{"}
  const [user, posts, comments] = await Promise.all([
    db.user.findFirst(),
    db.posts.findMany(),
    db.comments.findMany()
  ]);
  
  return ...
{"}"}
// max(200ms, 150ms, 100ms) = 200ms total

Pièges courants

Piège #1 : Passer des fonctions à Client Components

❌ Ne fonctionne pas

// Server Component
async function Page() {"{"}
  const handleClick = () => console.log('clicked');
  
  return <Button onClick={"{"}handleClick{"}"} />;  // Erreur !
{"}"}

Raison : fonctions non sérialisables

✅ Solution

// app/page.tsx (Server)
export default function Page() {"{"}
  return <Button />;
{"}"}

// app/Button.tsx (Client)
'use client';

export function Button() {"{"}
  const handleClick = () => console.log('clicked');
  return <button onClick={"{"}handleClick{"}"}>Click</button>;
{"}"}

Piège #2 : Importer Server Component dans Client Component

❌ Ne fonctionne pas

// ClientComponent.tsx
'use client';

import {"{"} ServerComponent {"}"} from './ServerComponent';  // Erreur !

export function ClientComponent() {"{"}
  return <ServerComponent />;
{"}"}

✅ Solution : passer en children

// page.tsx (Server)
export default function Page() {"{"}
  return (
    <ClientComponent>
      <ServerComponent />  {"{"}/* OK */{"}"}
    </ClientComponent>
  );
{"}"}

// ClientComponent.tsx
'use client';

export function ClientComponent({"{"} children {"}"}) {"{"}
  return <div>{"{"}children{"}"}</div>;
{"}"}

Piège #3 : Context providers

❌ Problème

// layout.tsx
export default function RootLayout({"{"} children {"}"}) {"{"}
  return (
    <ThemeProvider>  {"{"}/* Erreur : Context = Client Component */{"}"}
      {"{"}children{"}"}
    </ThemeProvider>
  );
{"}"}

✅ Solution : wrapper client

// app/providers.tsx
'use client';

export function Providers({"{"} children {"}"}) {"{"}
  return (
    <ThemeProvider>
      <AuthProvider>
        {"{"}children{"}"}
      </AuthProvider>
    </ThemeProvider>
  );
{"}"}

// layout.tsx (Server)
import {"{"} Providers {"}"} from './providers';

export default function RootLayout({"{"} children {"}"}) {"{"}
  return (
    <html>
      <body>
        <Providers>
          {"{"}children{"}"}
        </Providers>
      </body>
    </html>
  );
{"}"}

Migration depuis Pages Router

Étape 1 : Identifier les composants

// pages/dashboard.tsx (avant)
export default function Dashboard({"{"} data {"}"}) {"{"}
  const [filter, setFilter] = useState('all');
  
  return (
    <div>
      <Stats data={"{"}data{"}"} />  {"{"}/* Peut être Server Component */{"}"}
      <Filter value={"{"}filter{"}"} onChange={"{"}setFilter{"}"} />  {"{"}/* Client */{"}"}
      <Table data={"{"}data{"}"} filter={"{"}filter{"}"} />  {"{"}/* Client */{"}"}
    </div>
  );
{"}"}

Étape 2 : Séparer Server/Client

// app/dashboard/page.tsx (Server Component)
async function Dashboard() {"{"}
  const data = await db.getData();
  
  return (
    <div>
      <Stats data={"{"}data{"}"} />  {"{"}/* Server Component */{"}"}
      <DashboardClient data={"{"}data{"}"} />
    </div>
  );
{"}"}

// app/dashboard/DashboardClient.tsx
'use client';

export function DashboardClient({"{"} data {"}"}) {"{"}
  const [filter, setFilter] = useState('all');
  
  return (
    <>
      <Filter value={"{"}filter{"}"} onChange={"{"}setFilter{"}"} />
      <Table data={"{"}data{"}"} filter={"{"}filter{"}"} />
    </>
  );
{"}"}

Étape 3 : Supprimer getServerSideProps

Avant

export async function getServerSideProps() {"{"}
  const data = await fetch('...');
  return {"{"} props: {"{"} data {"}"} {"}"};
{"}"}

Après

async function Page() {"{"}
  const data = await fetch('...');  // Direct dans le composant
  return ...
{"}"}

Checklist de migration

Analyse
[ ] Identifier composants interactifs (Client)
[ ] Identifier composants statiques (Server)
[ ] Lister les Context providers

Migration
[ ] Créer app/ directory
[ ] Migrer layouts
[ ] Convertir pages en Server Components
[ ] Extraire Client Components
[ ] Wrapper les providers

Optimisation
[ ] Ajouter Suspense boundaries
[ ] Paralléliser data fetching
[ ] Configurer cache/revalidate

Validation
[ ] Tests passent
[ ] Bundle size réduit
[ ] Lighthouse score amélioré

Gains réels

Projet Next.js 13 → 14 (App Router + RSC)

Avant

Après

Impact business : taux de rebond -22%, conversions +18%


Questions sur les React Server Components ? Contactez-moi pour un audit de votre app Next.js.