Changer le thème

Guide complet de récupération de données dans les Server Components Next.js : fetch, requêtes BDD et bonnes pratiques

Easton editorial illustration: route-map drafting table

La première fois que j’ai écrit un composant dans Next.js App Router, j’ai vu ce code :

async function Page() {
  const data = await fetch('...')
  return <div>{data}</div>
}

C’est tout ? Un simple await ? Pas de useEffect, pas de useState, pas de souci de conditions de course ?

J’étais un peu perdu. Habitué au modèle client React, on me dit soudain que « les composants peuvent être asynchrones » — comme si les règles avaient changé. Et la question qui trotte : fetch API ou requête directe en base ? fetch, ça ajoute une couche API ; base directe, et la peur d’exposer les clés côté client.

Si vous partagez ces doutes, cet article est pour vous. On voit la bonne approche pour la récupération de données dans les Server Components Next.js — quand utiliser fetch, quand interroger la base, comment écrire un composant async, comment contrôler le cache, et les pièges fréquents.

Bases de la récupération de données dans les Server Components

Pourquoi les Server Components peuvent await directement ?

La réponse : les Server Components s’exécutent côté serveur, pas dans le navigateur.

Évident, mais crucial. Les composants React classiques tournent dans le navigateur — pas d’accès direct à la base ou au système de fichiers. Les Server Components s’exécutent sur le serveur, donc tout ce qu’on faisait dans les routes API :

  • Connexion directe à la base (Prisma, Drizzle, SQL natif)
  • Lecture du système de fichiers (fichiers markdown, etc.)
  • Appels à des services internes (sans souci de cross-origin)
  • Variables d’environnement et secrets (jamais envoyés au client)

Ce code est donc totalement sûr :

// app/posts/page.tsx
import { db } from '@/lib/db'

async function PostsPage() {
  // Requête directe — les clés ne partent pas vers le navigateur
  const posts = await db.post.findMany()

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

export default PostsPage

Points clés :

  1. Composant déclaré async function
  2. await directement sur la requête BDD
  3. Pas de hooks React (useState, useEffect, etc.)
  4. Par défaut, rendu côté serveur — le client ne reçoit que le HTML

Trois principales façons de récupérer des données

Dans un Server Component, trois options :

1. fetch API

La plus familière — API externe ou votre propre Route Handler :

async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()
  return <div>{data.title}</div>
}

2. Requête directe en base

ORM ou client natif :

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

async function Page() {
  const data = await db.posts.findFirst()
  return <div>{data.title}</div>
}

3. Server Actions

Pour les mutations (formulaires, suppressions) — pas seulement lire, aussi modifier :

async function createPost(formData: FormData) {
  'use server'
  const title = formData.get('title')
  await db.post.create({ data: { title } })
}

Lequel choisir ? On y revient plus bas.

fetch vs requête BDD — comment choisir ?

C’était ma grande hésitation au début avec App Router. Avec du recul, la réponse est assez claire.

Arbre de décision : 5 secondes

Trois questions :

  1. C’est un Server Component ? → Oui, continuez ; non (Client Component), passez à la question 3
  2. D’où viennent les données ?
    • Votre base → requête directe
    • API tierce → fetch
  3. Besoin depuis un Client Component ? → Créer une route API, puis fetch

C’est tout.

Pourquoi privilégier la base directe ?

La doc Next.js est claire : dans un Server Component, ne contournez pas par une route API — interrogez directement.

Raisons concrètes :

1. Un aller-retour HTTP en moins

// ❌ Détour : Server Component → API Route → Database
async function Page() {
  const res = await fetch('/api/posts')  // Appel HTTP
  const posts = await res.json()
  return <PostList posts={posts} />
}

// ✅ Direct : Server Component → Database
async function Page() {
  const posts = await db.post.findMany()  // Requête directe
  return <PostList posts={posts} />
}

La seconde voie évite une couche — réponse plus rapide. « De combien ? » — cumulé, 100–200 ms de gain se voient.

2. Meilleure sécurité de typage

Avec TypeScript + Prisma/Drizzle, typage complet :

// Inférence automatique, autocomplétion parfaite
const post = await db.post.findFirst({
  include: { author: true, comments: true }
})

// post.author.name ← typé
// post.comments[0].content ← idem

Avec fetch, types manuels ou as — plus d’erreurs.

3. Code plus simple

Pas de fichier API supplémentaire, pas de codes HTTP à gérer — moitié moins de code.

Quand l’API/fetch reste obligatoire

Trois cas :

Cas 1 : le Client Component a besoin des données

Impossible d’interroger la base depuis le navigateur — il faut un endpoint :

// app/api/posts/route.ts
export async function GET() {
  const posts = await db.post.findMany()
  return Response.json(posts)
}

// components/client-posts.tsx
'use client'
export function ClientPosts() {
  const [posts, setPosts] = useState([])

  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(setPosts)
  }, [])

  return <div>{/* render posts */}</div>
}

Cas 2 : API publique pour d’autres services

App mobile, tiers — endpoints publics nécessaires.

Cas 3 : services tiers

GitHub API, OpenAI API, etc. — fetch direct :

async function Page() {
  const res = await fetch('https://api.github.com/users/vercel')
  const user = await res.json()
  return <div>Followers: {user.followers}</div>
}

La bonne syntaxe async/await

Mode de base : d’une simplicité déconcertante

async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({
    where: { id: params.id }
  })

  if (!product) {
    return <div>Product not found</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}</p>
    </div>
  )
}

Pas de loading, pas de useEffect — on attend les données, on rend. Propre.

Parallèle vs séquentiel : écart de perf énorme

Piège que j’ai connu. Lequel est le plus rapide ?

// ❌ Séquentiel : lent
async function Page() {
  const user = await db.user.findFirst()      // 200 ms
  const posts = await db.post.findMany()      // +150 ms
  const comments = await db.comment.findMany()  // +100 ms
  // Total 450 ms
  return <Dashboard user={user} posts={posts} comments={comments} />
}

// ✅ Parallèle : rapide
async function Page() {
  const [user, posts, comments] = await Promise.all([
    db.user.findFirst(),
    db.post.findMany(),
    db.comment.findMany(),
  ])
  // Total 200 ms (le plus lent)
  return <Dashboard user={user} posts={posts} comments={comments} />
}

Plus de 2× d’écart ! Sans dépendance entre requêtes, Promise.all obligatoire.

Avec dépendance, séquentiel :

// Séquentiel obligatoire : la 2e dépend de la 1re
async function Page({ params }) {
  const user = await db.user.findUnique({ where: { id: params.id } })
  const posts = await db.post.findMany({ where: { authorId: user.id } })
  return <Profile user={user} posts={posts} />
}

Suspense : contrôler l’expérience de chargement

« Les données arrivent côté serveur — l’utilisateur voit un écran blanc ? »

Oui, mais loading.js et Suspense améliorent ça.

Méthode 1 : fichier loading.js

// app/posts/loading.tsx
export default function Loading() {
  return <div>Loading posts...</div>
}

// app/posts/page.tsx
async function PostsPage() {
  const posts = await db.post.findMany()
  return <PostList posts={posts} />
}

L’utilisateur voit d’abord « Loading posts… », puis le contenu.

Méthode 2 : Suspense manuel

import { Suspense } from 'react'

async function SlowComponent() {
  const data = await slowQuery()  // 3 s
  return <div>{data}</div>
}

async function FastComponent() {
  const data = await fastQuery()  // 0,5 s
  return <div>{data}</div>
}

export default function Page() {
  return (
    <div>
      <FastComponent />
      <Suspense fallback={<div>Loading...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

Erreur fréquente : mauvais emplacement de Suspense

// ❌ Suspense à l'intérieur du composant async — inefficace
async function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      {await slowQuery()}
    </Suspense>
  )
}

// ✅ Suspense enveloppe le composant async de l'extérieur
export default function Layout() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <SlowPage />
    </Suspense>
  )
}

Suspense doit être à l’extérieur du composant async pour intercepter la promesse.

Déduplication automatique des requêtes

Même requête appelée plusieurs fois dans un rendu — Next.js déduplique :

async function Header() {
  const user = await db.user.findFirst()  // Requête 1
  return <div>{user.name}</div>
}

async function Sidebar() {
  const user = await db.user.findFirst()  // Requête 2 — pas réellement exécutée
  return <div>{user.name}</div>
}

export default function Page() {
  return (
    <div>
      <Header />
      <Sidebar />
      {/* Une seule requête BDD en pratique */}
    </div>
  )
}

Next.js mémorise le premier résultat. Même source dans plusieurs composants — pas de souci perf.

Stratégies de cache et revalidation

Next.js 15 a changé les règles — beaucoup de pièges.

Changement de valeur par défaut en Next.js 15

Avant (Next.js 14) : fetch avec cache: 'force-cache' par défaut.

Maintenant (Next.js 15) : cache: 'no-store' par défaut — pas de cache.

Pourquoi ? Trop de développeurs pensaient que les données étaient à jour alors qu’elles restaient en cache. Le défaut « sans cache » paraît plus intuitif.

Conséquence : en passant de 14 à 15, les pages peuvent ralentir — les endpoints autrefois mis en cache sont rappelés à chaque fois.

Trois stratégies de cache

1. Cache complet (sites statiques)

async function BlogPost({ slug }) {
  const post = await fetch(`https://api.example.com/posts/${slug}`, {
    cache: 'force-cache'
  })
  return <article>{post.content}</article>
}

Articles de blog, fiches produit, documentation — contenu peu changeant.

2. Pas de cache (données temps réel)

async function StockPrice() {
  const price = await fetch('https://api.example.com/stock', {
    cache: 'no-store'
  })
  return <div>Current price: {price}</div>
}

Cours boursiers, commentaires live, statut utilisateur.

3. Revalidation planifiée (ISR)

async function ProductList() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }
  })
  return <div>{products.map(p => <Card key={p.id} {...p} />)}</div>
}

Listes produits, page d’accueil actualités — quelques secondes de retard acceptables.

Revalidation manuelle

Après une mutation (nouveau post), rafraîchir le cache immédiatement :

1. revalidatePath (toute la page)

'use server'
import { revalidatePath } from 'next/cache'

async function createPost(formData: FormData) {
  await db.post.create({ data: {...} })
  revalidatePath('/posts')
}

2. revalidateTag (par tag)

async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }
  })
  return res.json()
}

'use server'
import { revalidateTag } from 'next/cache'

async function createPost() {
  await db.post.create({ data: {...} })
  revalidateTag('posts')
}

Gestion d’erreurs et optimisation

Ne pas laisser la page crasher

Échec de récupération — la page entière peut planter. À gérer.

Méthode 1 : try/catch

async function Page() {
  try {
    const data = await fetch('https://api.example.com/data')
    if (!data.ok) throw new Error('Failed to fetch')
    return <div>{data.title}</div>
  } catch (error) {
    return <div>Something went wrong. Please try again.</div>
  }
}

Méthode 2 : fichier error.js

// app/posts/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Piège : redirect intercepté par try/catch

// ❌ redirect capturé par catch
async function Page() {
  try {
    const user = await getUser()
    if (!user) redirect('/login')
  } catch (error) {
    return <div>Error</div>
  }
}

// ✅ redirect hors du try/catch
async function Page() {
  let user
  try {
    user = await getUser()
  } catch (error) {
    return <div>Error</div>
  }

  if (!user) redirect('/login')
}

Erreurs courantes et solutions

Erreur 1 : chemin relatif en fetch côté serveur

// ❌ Pas de base URL côté serveur
async function Page() {
  const data = await fetch('/api/posts')
}

// ✅ URL absolue
async function Page() {
  const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`)
}

// ✅ Mieux : base directe
async function Page() {
  const posts = await db.post.findMany()
}

Erreur 2 : oublier response.ok

// ❌ fetch ne lance pas d'erreur sur 404
async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()
  return <div>{data.title}</div>
}

// ✅ Vérifier le statut
async function Page() {
  const res = await fetch('https://api.example.com/data')

  if (!res.ok) {
    throw new Error(`HTTP error! status: ${res.status}`)
  }

  const data = await res.json()
  return <div>{data.title}</div>
}

Erreur 3 : appeler un Route Handler depuis un Server Component

// ❌ Contournement inutile
async function Page() {
  const res = await fetch('/api/posts')
  const posts = await res.json()
  return <PostList posts={posts} />
}

// ✅ Requête directe
async function Page() {
  const posts = await db.post.findMany()
  return <PostList posts={posts} />
}

Cas pratique : page de blog

Article détail avec contenu, auteur et recommandations.

Structure

app/
  posts/
    [slug]/
      page.tsx
      loading.tsx
      error.tsx

Code

// app/posts/[slug]/page.tsx
import { db } from '@/lib/prisma'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'

export default async function PostPage({
  params,
}: {
  params: { slug: string }
}) {
  const [post, author] = await Promise.all([
    db.post.findUnique({
      where: { slug: params.slug },
    }),
    db.user.findFirst(),
  ])

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <AuthorCard author={author} />
      <div>{post.content}</div>

      <Suspense fallback={<div>Loading recommendations...</div>}>
        <RecommendedPosts currentPostId={post.id} />
      </Suspense>
    </article>
  )
}

function AuthorCard({ author }) {
  return (
    <div>
      <img src={author.avatar} alt={author.name} />
      <span>{author.name}</span>
    </div>
  )
}

async function RecommendedPosts({ currentPostId }: { currentPostId: string }) {
  const recommended = await db.post.findMany({
    where: {
      id: { not: currentPostId },
      published: true,
    },
    take: 3,
  })

  return (
    <div>
      <h3>You might also like</h3>
      {recommended.map((post) => (
        <a key={post.id} href={`/posts/${post.slug}`}>
          {post.title}
        </a>
      ))}
    </div>
  )
}

export const revalidate = 3600

export async function generateStaticParams() {
  const posts = await db.post.findMany({
    select: { slug: true },
  })

  return posts.map((post) => ({
    slug: post.slug,
  }))
}
// app/posts/[slug]/loading.tsx
export default function Loading() {
  return (
    <div>
      <div className="skeleton h-12 w-3/4" />
      <div className="skeleton h-4 w-1/4 mt-4" />
      <div className="skeleton h-64 mt-8" />
    </div>
  )
}
// app/posts/[slug]/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Failed to load post</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Choix expliqués

  1. Base directe ? → Server Component, pas besoin de route API
  2. Parallèle article + auteur ? → Pas de dépendance, plus rapide
  3. Suspense pour recommandations ? → Secondaire, ne bloque pas le contenu principal
  4. revalidate: 3600 ? → Contenu stable, 1 h de cache suffit

Conclusion

L’essentiel :

  1. Privilégier la base directe dans un Server Component, sauf Client Component ou API tierce.
  2. async/await simplePromise.all en parallèle, Suspense pour le chargement.
  3. Next.js 15 sans cache par défaut — choisir force-cache, no-store ou revalidate.
  4. Gérer les erreursresponse.ok, error.tsx, redirect hors try/catch.

La récupération de données en Server Components est bien plus simple qu’en client : pas de loading, pas de conditions de course, pas d’annulation. Si vous hésitez sur App Router, ce seul point vaut le coup.

Sur votre prochain projet, lancez-vous — et revenez ici si besoin.

Flux complet de récupération de données dans les Server Components Next.js

De fetch vs requête BDD à la syntaxe async, stratégies de cache et gestion d'erreurs

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: Choisir fetch vs requête BDD

    Requête directe (recommandé) :
    • Plus rapide : moins d'appels API, latence plus basse
    • Plus sûr : les clés BDD ne sont pas exposées au client
    • Usage : base accessible côté serveur

    Exemple :
    ```tsx
    import { db } from '@/lib/db'

    export default async function Page() {
    const users = await db.user.findMany()
    return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>
    }
    ```

    Utiliser fetch :
    • APIs tierces
    • Cas cross-origin
    • Attention : Next.js 15 ne met pas en cache par défaut

    Exemple :
    ```tsx
    export default async function Page() {
    const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache'
    })
    const data = await res.json()
    return <div>{data}</div>
    }
    ```

    Conseil : privilégier la base directe ; fetch seulement pour APIs tierces
  2. 2

    Step 2: Syntaxe async/await des composants

    Marquer async :
    ```tsx
    export default async function Page() {
    const data = await fetchData()
    return <div>{data}</div>
    }
    ```

    Parallèle (Promise.all) :
    ```tsx
    export default async function Page() {
    const [users, posts] = await Promise.all([
    fetchUsers(),
    fetchPosts()
    ])
    return <div>...</div>
    }
    ```

    Suspense pour le chargement :
    ```tsx
    import { Suspense } from 'react'

    export default function Page() {
    return (
    <Suspense fallback={<div>Loading...</div>}>
    <UserList />
    </Suspense>
    )
    }

    async function UserList() {
    const users = await fetchUsers()
    return <div>{users.map(...)}</div>
    }
    ```

    Points clés :
    • Server Components peuvent être async
    • Promise.all pour le parallèle
    • Suspense pour l'expérience de chargement
  3. 3

    Step 3: Configurer la stratégie de cache

    Next.js 15 : pas de cache par défaut, configuration explicite :

    Sans cache (temps réel) :
    ```tsx
    fetch(url, { cache: 'no-store' })
    ```

    Cache permanent (statique) :
    ```tsx
    fetch(url, { cache: 'force-cache' })
    ```

    Mise à jour planifiée (ISR) :
    ```tsx
    fetch(url, { next: { revalidate: 3600 } })
    ```

    Conseils :
    • Temps réel → cache: 'no-store'
    • Statique → cache: 'force-cache'
    • Mises à jour fréquentes sans temps réel → revalidate

    Next.js 15 : explicite > implicite — réfléchir à ce qui doit être mis en cache.
  4. 4

    Step 4: Gestion d'erreurs

    Vérifier response.ok :
    ```tsx
    const res = await fetch(url)
    if (!res.ok) {
    throw new Error('Failed to fetch')
    }
    const data = await res.json()
    ```

    Filet error.tsx :
    ```tsx
    // app/page/error.tsx
    'use client'
    export default function Error({ error, reset }) {
    return (
    <div>
    <h2>Erreur : {error.message}</h2>
    <button onClick={reset}>Réessayer</button>
    </div>
    )
    }
    ```

    Attention redirect :
    ```tsx
    // ❌ redirect dans try/catch
    try {
    if (!user) redirect('/login')
    } catch (e) {
    // redirect lance une erreur capturée ici
    }

    // ✅ redirect hors try/catch
    if (!user) redirect('/login')
    try {
    // autre logique
    } catch (e) {
    // gestion d'erreurs
    }
    ```

    Points clés :
    • Vérifier response.ok
    • error.tsx en filet
    • redirect hors try/catch

FAQ

Pourquoi les Server Components peuvent await directement ?
Raison : les Server Components s'exécutent côté serveur, pas dans le navigateur.

Ils peuvent :
• Se connecter directement à la base (Prisma, Drizzle, SQL natif)
• Lire le système de fichiers (fichiers markdown, etc.)
• Appeler des services internes (sans cross-origin)
• Accéder aux variables d'environnement et secrets (non exposés au client)

Code sûr :
```tsx
import { db } from '@/lib/db'

export default async function Page() {
const users = await db.user.findMany()
return <div>{users.map(...)}</div>
}
```

Avantages :
• Pas de useEffect, useState
• Pas de conditions de course
• Pas de gestion de loading
• Récupération bien plus simple qu'en client
Quand utiliser fetch, quand interroger la base directement ?
Base directe (recommandé) :
• Plus rapide : serveur → BDD <10 ms vs client → serveur 100 ms+
• Plus sûr : clés non exposées
• Usage : base accessible côté serveur

fetch :
• APIs tierces
• Cross-origin
• Architecture API déjà en place

Conseil :
• Privilégier la base directe
• fetch seulement pour APIs tierces
• Éviter fetch('/api/...') depuis un Server Component

Comparaison :
```tsx
// ❌ Anti-pattern
const res = await fetch('/api/users')
const users = await res.json()

// ✅ Correct
const users = await db.user.findMany()
```
Comment écrire un composant async ?
Marquer async :
```tsx
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
```

Parallèle :
```tsx
export default async function Page() {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
])
return <div>...</div>
}
```

Suspense :
```tsx
import { Suspense } from 'react'

export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserList />
</Suspense>
)
}

async function UserList() {
const users = await fetchUsers()
return <div>{users.map(...)}</div>
}
```

Points clés : async direct, Promise.all, Suspense
Comment configurer le cache en Next.js 15 ?
Pas de cache par défaut — configuration explicite :

Temps réel :
```tsx
fetch(url, { cache: 'no-store' })
```

Statique :
```tsx
fetch(url, { cache: 'force-cache' })
```

ISR :
```tsx
fetch(url, { next: { revalidate: 3600 } })
```

Conseils :
• Temps réel → no-store
• Statique → force-cache
• Fréquent sans temps réel → revalidate

Changement breaking à la migration 14 → 15.
Comment gérer les erreurs dans les Server Components ?
Vérifier response.ok :
```tsx
const res = await fetch(url)
if (!res.ok) {
throw new Error('Failed to fetch')
}
const data = await res.json()
```

error.tsx :
```tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Erreur : {error.message}</h2>
<button onClick={reset}>Réessayer</button>
</div>
)
}
```

redirect :
```tsx
// ❌ dans try/catch
try {
if (!user) redirect('/login')
} catch (e) {}

// ✅ hors try/catch
if (!user) redirect('/login')
```

Points clés : response.ok, error.tsx, redirect hors try/catch

11 min de lecture · Publié le: 19 déc. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog